diff --git a/BUILD.md b/BUILD.md index 0cb88b0ef..0c050418e 100644 --- a/BUILD.md +++ b/BUILD.md @@ -24,3 +24,16 @@ To only build the `eclair-node` module $ mvn install -pl eclair-node -am -DskipTests ``` +# Building the API documentation + +## Slate + +The API doc is generated via slate and hosted on github pages. To make a change and update the doc follow the steps: + +1. git checkout slate-doc +2. Install your local dependencies for slate, more info [here](https://github.com/lord/slate#getting-started-with-slate) +3. Edit `source/index.html.md` and save your changes. +4. Commit all the changes to git, before deploying the repo should be clean. +5. Push your commit to remote. +6. Run `./deploy.sh` +7. Wait a few minutes and the doc should be updated at https://acinq.github.io/eclair \ No newline at end of file diff --git a/OLD-API-DOCS.md b/OLD-API-DOCS.md new file mode 100644 index 000000000..1c7f30abe --- /dev/null +++ b/OLD-API-DOCS.md @@ -0,0 +1,40 @@ + ## JSON-RPC API + + :warning: Note this interface is being deprecated. + + method | params | description + ------------- |----------------------------------------------------------------------------------------|----------------------------------------------------------- + getinfo | | return basic node information (id, chain hash, current block height) + connect | nodeId, host, port | open a secure connection to a lightning node + connect | uri | open a secure connection to a lightning node + open | nodeId, fundingSatoshis, pushMsat = 0, feerateSatPerByte = ?, channelFlags = 0x01 | open a channel with another lightning node, by default push = 0, feerate for the funding tx targets 6 blocks, and channel is announced + updaterelayfee | channelId, feeBaseMsat, feeProportionalMillionths | update relay fee for payments going through this channel + peers | | list existing local peers + channels | | list existing local channels + channels | nodeId | list existing local channels opened with a particular nodeId + channel | channelId | retrieve detailed information about a given channel + channelstats | | retrieves statistics about channel usage (fees, number and average amount of payments) + allnodes | | list all known nodes + allchannels | | list all known channels + allupdates | | list all channels updates + allupdates | nodeId | list all channels updates for this nodeId + receive | description | generate a payment request without a required amount (can be useful for donations) + receive | amountMsat, description | generate a payment request for a given amount + receive | amountMsat, description, expirySeconds | generate a payment request for a given amount that expires after given number of seconds + parseinvoice | paymentRequest | returns node, amount and payment hash in a payment request + findroute | paymentRequest | returns nodes and channels of the route for this payment request if there is any + findroute | paymentRequest, amountMsat | returns nodes and channels of the route for this payment request and amount, if there is any + findroute | nodeId, amountMsat | returns nodes and channels of the route to the nodeId, if there is any + send | amountMsat, paymentHash, nodeId | send a payment to a lightning node + send | paymentRequest | send a payment to a lightning node using a BOLT11 payment request + send | paymentRequest, amountMsat | send a payment to a lightning node using a BOLT11 payment request and a custom amount + checkpayment | paymentHash | returns true if the payment has been received, false otherwise + checkpayment | paymentRequest | returns true if the payment has been received, false otherwise + close | channelId | close a channel + close | channelId, scriptPubKey | close a channel and send the funds to the given scriptPubKey + forceclose | channelId | force-close a channel by publishing the local commitment tx (careful: this is more expensive than a regular close and will incur a delay before funds are spendable)" + audit | | list all send/received/relayed payments + audit | from, to | list send/received/relayed payments in that interval (from <= timestamp < to) + networkfees | | list all network fees paid to the miners, by transaction + networkfees |from, to | list network fees paid to the miners, by transaction, in that interval (from <= timestamp < to) + help | | display available methods diff --git a/README.md b/README.md index 3cbf72208..d5c1db6b0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![Gitter chat](https://img.shields.io/badge/chat-on%20gitter-red.svg)](https://gitter.im/ACINQ/eclair) -**Eclair** (French for Lightning) is a Scala implementation of the Lightning Network. It can run with or without a GUI, and a JSON-RPC API is also available. +**Eclair** (French for Lightning) is a Scala implementation of the Lightning Network. It can run with or without a GUI, and a JSON API is also available. This software follows the [Lightning Network Specifications (BOLTs)](https://github.com/lightningnetwork/lightning-rfc). Other implementations include [c-lightning](https://github.com/ElementsProject/lightning) and [lnd](https://github.com/LightningNetwork/lnd). @@ -14,7 +14,7 @@ This software follows the [Lightning Network Specifications (BOLTs)](https://git :rotating_light: If you intend to run Eclair on mainnet: - Keep in mind that it is beta-quality software and **don't put too much money** in it - - Eclair's JSON-RPC API should **NOT** be accessible from the outside world (similarly to Bitcoin Core API) + - Eclair's JSON API should **NOT** be accessible from the outside world (similarly to Bitcoin Core API) - Specific [configuration instructions for mainnet](#mainnet-usage) are provided below (by default Eclair runs on testnet) --- @@ -128,44 +128,14 @@ Eclair uses [`logback`](https://logback.qos.ch) for logging. To use a different java -Dlogback.configurationFile=/path/to/logback-custom.xml -jar eclair-node-gui--.jar ``` -## JSON-RPC API +## JSON API + +Eclair offers a feature rich HTTP API that enables application developers to easily integrate. + +For more information please visit the [API documentation website](https://acinq.github.io/eclair). + +:warning: You can still use the old API by setting the `eclair.api.use-old-api=true` parameter, but it is now deprecated and will soon be removed. The old documentation is still available [here](https://github.com/ACINQ/eclair/OLD-API-DOCS.md). - method | params | description - ------------- |----------------------------------------------------------------------------------------|----------------------------------------------------------- - getinfo | | return basic node information (id, chain hash, current block height) - connect | nodeId, host, port | open a secure connection to a lightning node - connect | uri | open a secure connection to a lightning node - open | nodeId, fundingSatoshis, pushMsat = 0, feerateSatPerByte = ?, channelFlags = 0x01 | open a channel with another lightning node, by default push = 0, feerate for the funding tx targets 6 blocks, and channel is announced - updaterelayfee | channelId, feeBaseMsat, feeProportionalMillionths | update relay fee for payments going through this channel - peers | | list existing local peers - channels | | list existing local channels - channels | nodeId | list existing local channels opened with a particular nodeId - channel | channelId | retrieve detailed information about a given channel - channelstats | | retrieves statistics about channel usage (fees, number and average amount of payments) - allnodes | | list all known nodes - allchannels | | list all known channels - allupdates | | list all channels updates - allupdates | nodeId | list all channels updates for this nodeId - receive | description | generate a payment request without a required amount (can be useful for donations) - receive | amountMsat, description | generate a payment request for a given amount - receive | amountMsat, description, expirySeconds | generate a payment request for a given amount that expires after given number of seconds - parseinvoice | paymentRequest | returns node, amount and payment hash in a payment request - findroute | paymentRequest | returns nodes and channels of the route for this payment request if there is any - findroute | paymentRequest, amountMsat | returns nodes and channels of the route for this payment request and amount, if there is any - findroute | nodeId, amountMsat | returns nodes and channels of the route to the nodeId, if there is any - send | amountMsat, paymentHash, nodeId | send a payment to a lightning node - send | paymentRequest | send a payment to a lightning node using a BOLT11 payment request - send | paymentRequest, amountMsat | send a payment to a lightning node using a BOLT11 payment request and a custom amount - checkpayment | paymentHash | returns true if the payment has been received, false otherwise - checkpayment | paymentRequest | returns true if the payment has been received, false otherwise - close | channelId | close a channel - close | channelId, scriptPubKey | close a channel and send the funds to the given scriptPubKey - forceclose | channelId | force-close a channel by publishing the local commitment tx (careful: this is more expensive than a regular close and will incur a delay before funds are spendable)" - audit | | list all send/received/relayed payments - audit | from, to | list send/received/relayed payments in that interval (from <= timestamp < to) - networkfees | | list all network fees paid to the miners, by transaction - networkfees |from, to | list network fees paid to the miners, by transaction, in that interval (from <= timestamp < to) - help | | display available methods ## Docker diff --git a/contrib/eclair-cli.bash-completion b/contrib/eclair-cli.bash-completion index b88f979f6..a5e846baf 100644 --- a/contrib/eclair-cli.bash-completion +++ b/contrib/eclair-cli.bash-completion @@ -21,7 +21,7 @@ _eclair-cli() *) # works fine, but is too slow at the moment. # allopts=$($eclaircli help 2>&1 | awk '$1 ~ /^"/ { sub(/,/, ""); print $1}' | sed 's/[":]//g') - allopts="connect open peers channels channel allnodes allchannels allupdates receive send close audit findroute updaterelayfee parseinvoice forceclose networkfees channelstats checkpayment getinfo help" + allopts="getinfo connect open close forceclose updaterelayfee peers channels channel allnodes allchannels allupdates receive parseinvoice findroute findroutetonode send sendtonode checkpayment audit networkfees channelstats" if ! [[ " $allopts " =~ " $prev " ]]; then # prevent double arguments if [[ -z "$cur" || "$cur" =~ ^[a-z] ]]; then diff --git a/eclair-core/eclair-cli b/eclair-core/eclair-cli index 66e9cd358..f002c9531 100755 --- a/eclair-core/eclair-cli +++ b/eclair-core/eclair-cli @@ -1,104 +1,119 @@ #!/bin/bash + +# default script values, can be overriden for convenience. +api_url='http://localhost:8080' +# uncomment the line below if you don't want to provide a password each time you call eclair-cli +# api_password='your_api_password' +# for some commands the json output can be shortened for better readability +short=false + +# prints help message +usage() { + echo -e "============================== +Command line client for eclair +============================== + +This tool requires the eclair node's API to be enabled and listening +on <$api_url>. + +Usage +----- +\e[93meclair-cli\e[39m [\e[93mOPTIONS\e[39m]... [\e[93mCOMMAND\e[39m] [--command-param command-value]... + +where OPTIONS can be: + -p API's password + -a
Override the API URL with
+ -h Show available commands + +and COMMAND is one of: + getinfo, connect, open, close, forceclose, updaterelayfee, + peers, channels, channel, allnodes, allchannels, allupdates, + receive, parseinvoice, findroute, findroutetonode, + send, sendtonode, checkpayment, + audit, networkfees, channelstats + +Examples +-------- + eclair-cli help display available commands + eclair-cli -a localhost:1234 peers list the peers of a node hosted on localhost:1234 + eclair-cli close --channelId 006fb... closes the channel with id 006fb... + + +Full documentation here: " 1>&2; +exit 1; +} + +# -- script's logic begins here + # Check if jq is installed. If not, display instructions and abort program command -v jq >/dev/null 2>&1 || { echo -e "This tool requires jq.\nFor installation instructions, visit https://stedolan.github.io/jq/download/.\n\nAborting..."; exit 1; } # curl installed? If not, give a hint command -v curl >/dev/null 2>&1 || { echo -e "This tool requires curl.\n\nAborting..."; exit 1; } -FULL_OUTPUT='false' -URL='http://localhost:8080' -PASSWORD='' - -# -------------------- METHODS - -displayhelp() { - echo -e "Usage: eclair-cli [OPTION]... [COMMAND] -Client for an eclair node. - -With COMMAND is one of the command listed by \e[01;33meclair-cli help\e[0m. - - -p api's password - -a
Override the api URL with
- -v Outputs full json returned by the API - -Examples: - eclair-cli -a localhost:1234 peers list the peers - eclair-cli close 006fb... closes the channel with id 006fb... - -Note: Uses the json-rpc api exposed by the node on localhost:8080. Make sure the api is enabled. -Full documentation at: " -} - -# Executes a JSON RPC call to a node listening on ${URL} -call() { - jqexp='if .error == null then .result else .error.message end' - # override default jq parsing expression - if [ $# -ge 3 ] && [ ${FULL_OUTPUT} == "false" ]; then jqexp=${3}; fi - # set password - if [ -z ${PASSWORD} ]; then auth="eclair-cli"; - else auth="eclair-cli:"${PASSWORD}; fi - eval curl "--user ${auth} --silent --show-error -X POST -H \"Content-Type: application/json\" -d '{ \"method\": \"'${1}'\", \"params\": '${2}' }' ${URL}" | jq -r "$jqexp" -} - -# get script options -while getopts 'vu:p:a:' flag; do - case "${flag}" in - p) PASSWORD="${OPTARG}" ;; - a) URL="${OPTARG}" ;; - v) FULL_OUTPUT="true" ;; - *) echo -e "\nAborting..."; exit 1; ;; - esac +# extract script options +while getopts ':cu:su:p:a:hu:' flag; do + case "${flag}" in + p) api_password="${OPTARG}" ;; + a) api_url="${OPTARG}" ;; + h) usage ;; + s) short=true ;; + *) ;; + esac done - shift $(($OPTIND - 1)) -# assigning JSON RPC method and params values from arguments -METHOD=${1} +# extract api's endpoint (e.g. sendpayment, connect, ...) from params +api_endpoint=${1} shift 1 -# Create a JSON Array containing the remaining program args as QUOTED STRINGS, separated with a `,` character -PARAMS="" -i=1 +# display a usage method if no method given or help requested +if [ -z $api_endpoint ] || [ "$api_endpoint" == "help" ]; then + usage; +fi + +# transform long options into a HTTP encoded url body. +api_payload="" +index=1 for arg in "${@}"; do - if [ $i -eq 1 ]; then PARAMS=$(printf '"%s"' "$arg"); - else PARAMS=$(printf '%s,"%s"' "$PARAMS" "$arg"); - fi - let "i++" + transformed_arg=""; + case ${arg} in + "--"*) # if arg begins with two dashes, it is the name of a parameter. Dashes must be removed, and arg must be followed by an equal sign + # also, it must be prefixed by an '&' sign, if it is not the first argument + if [ $index -eq 1 ]; then + transformed_arg="$transformed_arg${arg:2}="; + else + transformed_arg="&$transformed_arg${arg:2}="; + fi + ;; + *) transformed_arg=$arg + ;; + esac + api_payload="$api_payload$transformed_arg"; + let "index++" done; -PARAMS="[${PARAMS}]" -# Whatever the arguments provided to eclair-cli, a call to the API will be sent. Let it fail! -case ${METHOD}_${#} in - ""_*) displayhelp ;; - "help"*) displayhelp - echo -e "\nAvailable commands:\n" - call "help" [] ;; +# jq filter parses response body for error message +jq_filter='if type=="object" and .error != null then .error else .'; - "connect_3") call ${METHOD} "'$(printf '["%s","%s",%s]' "${1}" "${2}" "${3}")'" ;; # ${3} is numeric +# apply special jq filter if we are in "short" ouput mode -- only for specific commands such as 'channels' +if [ "$short" = true ]; then + jq_channel_filter="{ nodeId, shortChannelId: .data.shortChannelId, channelId, state, balanceSat: (try (.data.commitments.localCommit.spec.toLocalMsat / 1000 | floor) catch null), capacitySat: .data.commitments.commitInput.amountSatoshis, channelPoint: .data.commitments.commitInput.outPoint }"; + case $api_endpoint in + "channels") jq_filter="$jq_filter | map( $jq_channel_filter )" ;; + "channel") jq_filter="$jq_filter | $jq_channel_filter" ;; + *) ;; + esac +fi - "open_4") call ${METHOD} "'$(printf '["%s",%s,%s,%s]' "${1}" "${2}" "${3}" "${4}")'" ;; # ${2} ${3} ${4} are numeric (funding, push, flags) - "open_3") call ${METHOD} "'$(printf '["%s",%s,%s]' "${1}" "${2}" "${3}")'" ;; # ${2} ${3} are numeric (funding, push) - "open_2") call ${METHOD} "'$(printf '["%s",%s]' "${1}" "${2}")'" ;; # ${2} is numeric (funding) +jq_filter="$jq_filter end"; - "receive_2") call ${METHOD} "'$(printf '[%s,"%s"]' "${1}" "${2}")'" ;; # ${1} is numeric (amount to receive) - "receive_3") call ${METHOD} "'$(printf '[%s,"%s",%s]' "${1}" "${2}" "${3}")'" ;; # ${1} is numeric (amount to receive) as is ${2} for expiry in seconds +# if no password is provided, auth should only contain user login so that curl prompts for the api password +if [ -z $api_password ]; then + auth="eclair-cli"; +else + auth="eclair-cli:$api_password"; +fi - "channel_"*) call ${METHOD} "'${PARAMS}'" "if .error != null then .error.message else .result | { nodeId, shortChannelId: .data.shortChannelId, channelId, state, balanceSat: (try (.data.commitments.localCommit.spec.toLocalMsat / 1000 | floor) catch null), capacitySat: .data.commitments.commitInput.amountSatoshis, channelPoint: .data.commitments.commitInput.outPoint } end" ;; - - "channels_"*) call ${METHOD} "'${PARAMS}'" "if .error != null then .error.message else .result | map( { nodeId, shortChannelId: .data.shortChannelId, channelId, state, balanceSat: (try (.data.commitments.localCommit.spec.toLocalMsat / 1000 | floor) catch null), capacitySat: .data.commitments.commitInput.amountSatoshis, channelPoint: .data.commitments.commitInput.outPoint } ) end" ;; - - "send_3") call ${METHOD} "'$(printf '[%s,"%s","%s"]' "${1}" "${2}" "${3}")'" ;; # ${1} is numeric (amount of the payment) - "send_2") call ${METHOD} "'$(printf '["%s",%s]' "${1}" "${2}")'" ;; # ${2} is numeric (amount overriding the payment request) - - "audit_2") call ${METHOD} "'$(printf '[%s,%s]' "${1}" "${2}")'" ;; # ${1} and ${2} are numeric (unix timestamps) - - "networkfees_2") call ${METHOD} "'$(printf '[%s,%s]' "${1}" "${2}")'" ;; # ${1} and ${2} are numeric (unix timestamps) - - *) # Default case. - # Sends the method and, for parameters, use the JSON table containing the remaining args. - # - # NOTE: Arguments will be sent as QUOTED STRING so if this particular API call requires an INT param, - # this call will fail. In that case, a specific rule for that method MUST be set and the ${PARAMS} JSON array can not be used. - call ${METHOD} "'${PARAMS}'" ;; - -esac +# we're now ready to execute the API call +eval curl "--user $auth --silent --show-error -X POST -H \"Content-Type: application/x-www-form-urlencoded\" -d '$api_payload' $api_url/$api_endpoint" | jq -r "$jq_filter" diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 63863ca1a..672cc0a2b 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -13,6 +13,7 @@ eclair { binding-ip = "127.0.0.1" port = 8080 password = "" // password for basic auth, must be non empty if json-rpc api is enabled + use-old-api = false } watcher-type = "bitcoind" // other *experimental* values include "electrum" diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala new file mode 100644 index 000000000..729e00f07 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -0,0 +1,197 @@ +package fr.acinq.eclair + +import akka.actor.ActorRef +import akka.pattern._ +import akka.util.Timeout +import fr.acinq.bitcoin.Crypto.PublicKey +import fr.acinq.bitcoin.{ByteVector32, MilliSatoshi, Satoshi} +import fr.acinq.eclair.api.{AuditResponse, GetInfoResponse} +import fr.acinq.eclair.channel._ +import fr.acinq.eclair.db.{NetworkFee, Stats} +import fr.acinq.eclair.io.Peer.{GetPeerInfo, PeerInfo} +import fr.acinq.eclair.io.{NodeURI, Peer} +import fr.acinq.eclair.payment.PaymentLifecycle._ +import fr.acinq.eclair.payment.{PaymentLifecycle, PaymentRequest} +import fr.acinq.eclair.router.{ChannelDesc, RouteRequest, RouteResponse} +import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelUpdate, NodeAnnouncement} +import scodec.bits.ByteVector + +import scala.concurrent.Future +import scala.concurrent.duration._ + +trait Eclair { + + def connect(uri: String): Future[String] + + def open(nodeId: PublicKey, fundingSatoshis: Long, pushMsat: Option[Long], fundingFeerateSatByte: Option[Long], flags: Option[Int]): Future[String] + + def close(channelIdentifier: Either[ByteVector32, ShortChannelId], scriptPubKey: Option[ByteVector]): Future[String] + + def forceClose(channelIdentifier: Either[ByteVector32, ShortChannelId]): Future[String] + + def updateRelayFee(channelId: String, feeBaseMsat: Long, feeProportionalMillionths: Long): Future[String] + + def peersInfo(): Future[Iterable[PeerInfo]] + + def channelsInfo(toRemoteNode: Option[PublicKey]): Future[Iterable[RES_GETINFO]] + + def channelInfo(channelId: ByteVector32): Future[RES_GETINFO] + + def allnodes(): Future[Iterable[NodeAnnouncement]] + + def allchannels(): Future[Iterable[ChannelDesc]] + + def allupdates(nodeId: Option[PublicKey]): Future[Iterable[ChannelUpdate]] + + def receive(description: String, amountMsat: Option[Long], expire: Option[Long]): Future[String] + + def findRoute(targetNodeId: PublicKey, amountMsat: Long, assistedRoutes: Seq[Seq[PaymentRequest.ExtraHop]] = Seq.empty): Future[RouteResponse] + + def send(recipientNodeId: PublicKey, amountMsat: Long, paymentHash: ByteVector32, assistedRoutes: Seq[Seq[PaymentRequest.ExtraHop]] = Seq.empty, minFinalCltvExpiry: Option[Long] = None): Future[PaymentResult] + + def checkpayment(paymentHash: ByteVector32): Future[Boolean] + + def audit(from_opt: Option[Long], to_opt: Option[Long]): Future[AuditResponse] + + def networkFees(from_opt: Option[Long], to_opt: Option[Long]): Future[Seq[NetworkFee]] + + def channelStats(): Future[Seq[Stats]] + + def getInfoResponse(): Future[GetInfoResponse] + +} + +class EclairImpl(appKit: Kit) extends Eclair { + + implicit val ec = appKit.system.dispatcher + implicit val timeout = Timeout(60 seconds) // used by akka ask + + override def connect(uri: String): Future[String] = { + (appKit.switchboard ? Peer.Connect(NodeURI.parse(uri))).mapTo[String] + } + + override def open(nodeId: PublicKey, fundingSatoshis: Long, pushMsat: Option[Long], fundingFeerateSatByte: Option[Long], flags: Option[Int]): Future[String] = { + (appKit.switchboard ? Peer.OpenChannel( + remoteNodeId = nodeId, + fundingSatoshis = Satoshi(fundingSatoshis), + pushMsat = pushMsat.map(MilliSatoshi).getOrElse(MilliSatoshi(0)), + fundingTxFeeratePerKw_opt = fundingFeerateSatByte, + channelFlags = flags.map(_.toByte))).mapTo[String] + } + + override def close(channelIdentifier: Either[ByteVector32, ShortChannelId], scriptPubKey: Option[ByteVector]): Future[String] = { + sendToChannel(channelIdentifier.fold[String](_.toString(), _.toString()), CMD_CLOSE(scriptPubKey)).mapTo[String] + } + + override def forceClose(channelIdentifier: Either[ByteVector32, ShortChannelId]): Future[String] = { + sendToChannel(channelIdentifier.fold[String](_.toString(), _.toString()), CMD_FORCECLOSE).mapTo[String] + } + + override def updateRelayFee(channelId: String, feeBaseMsat: Long, feeProportionalMillionths: Long): Future[String] = { + sendToChannel(channelId, CMD_UPDATE_RELAY_FEE(feeBaseMsat, feeProportionalMillionths)).mapTo[String] + } + + override def peersInfo(): Future[Iterable[PeerInfo]] = for { + peers <- (appKit.switchboard ? 'peers).mapTo[Iterable[ActorRef]] + peerinfos <- Future.sequence(peers.map(peer => (peer ? GetPeerInfo).mapTo[PeerInfo])) + } yield peerinfos + + override def channelsInfo(toRemoteNode: Option[PublicKey]): Future[Iterable[RES_GETINFO]] = toRemoteNode match { + case Some(pk) => for { + channelsId <- (appKit.register ? 'channelsTo).mapTo[Map[ByteVector32, PublicKey]].map(_.filter(_._2 == pk).keys) + channels <- Future.sequence(channelsId.map(channelId => sendToChannel(channelId.toString(), CMD_GETINFO).mapTo[RES_GETINFO])) + } yield channels + case None => for { + channels_id <- (appKit.register ? 'channels).mapTo[Map[ByteVector32, ActorRef]].map(_.keys) + channels <- Future.sequence(channels_id.map(channel_id => sendToChannel(channel_id.toHex, CMD_GETINFO).mapTo[RES_GETINFO])) + } yield channels + } + + override def channelInfo(channelId: ByteVector32): Future[RES_GETINFO] = { + sendToChannel(channelId.toString(), CMD_GETINFO).mapTo[RES_GETINFO] + } + + override def allnodes(): Future[Iterable[NodeAnnouncement]] = (appKit.router ? 'nodes).mapTo[Iterable[NodeAnnouncement]] + + override def allchannels(): Future[Iterable[ChannelDesc]] = { + (appKit.router ? 'channels).mapTo[Iterable[ChannelAnnouncement]].map(_.map(c => ChannelDesc(c.shortChannelId, c.nodeId1, c.nodeId2))) + } + + override def allupdates(nodeId: Option[PublicKey]): Future[Iterable[ChannelUpdate]] = nodeId match { + case None => (appKit.router ? 'updates).mapTo[Iterable[ChannelUpdate]] + case Some(pk) => (appKit.router ? 'updatesMap).mapTo[Map[ChannelDesc, ChannelUpdate]].map(_.filter(e => e._1.a == pk || e._1.b == pk).values) + } + + override def receive(description: String, amountMsat: Option[Long], expire: Option[Long]): Future[String] = { + (appKit.paymentHandler ? ReceivePayment(description = description, amountMsat_opt = amountMsat.map(MilliSatoshi), expirySeconds_opt = expire)).mapTo[PaymentRequest].map { pr => + PaymentRequest.write(pr) + } + } + + override def findRoute(targetNodeId: PublicKey, amountMsat: Long, assistedRoutes: Seq[Seq[PaymentRequest.ExtraHop]] = Seq.empty): Future[RouteResponse] = { + (appKit.router ? RouteRequest(appKit.nodeParams.nodeId, targetNodeId, amountMsat, assistedRoutes)).mapTo[RouteResponse] + } + + override def send(recipientNodeId: PublicKey, amountMsat: Long, paymentHash: ByteVector32, assistedRoutes: Seq[Seq[PaymentRequest.ExtraHop]] = Seq.empty, minFinalCltvExpiry: Option[Long] = None): Future[PaymentResult] = { + val sendPayment = minFinalCltvExpiry match { + case Some(minCltv) => SendPayment(amountMsat, paymentHash, recipientNodeId, assistedRoutes, finalCltvExpiry = minCltv) + case None => SendPayment(amountMsat, paymentHash, recipientNodeId, assistedRoutes) + } + (appKit.paymentInitiator ? sendPayment).mapTo[PaymentResult].map { + case s: PaymentSucceeded => s + case f: PaymentFailed => f.copy(failures = PaymentLifecycle.transformForUser(f.failures)) + } + } + + override def checkpayment(paymentHash: ByteVector32): Future[Boolean] = { + (appKit.paymentHandler ? CheckPayment(paymentHash)).mapTo[Boolean] + } + + override def audit(from_opt: Option[Long], to_opt: Option[Long]): Future[AuditResponse] = { + val (from, to) = (from_opt, to_opt) match { + case (Some(f), Some(t)) => (f, t) + case _ => (0L, Long.MaxValue) + } + + Future(AuditResponse( + sent = appKit.nodeParams.db.audit.listSent(from, to), + received = appKit.nodeParams.db.audit.listReceived(from, to), + relayed = appKit.nodeParams.db.audit.listRelayed(from, to) + )) + } + + override def networkFees(from_opt: Option[Long], to_opt: Option[Long]): Future[Seq[NetworkFee]] = { + val (from, to) = (from_opt, to_opt) match { + case (Some(f), Some(t)) => (f, t) + case _ => (0L, Long.MaxValue) + } + + Future(appKit.nodeParams.db.audit.listNetworkFees(from, to)) + } + + override def channelStats(): Future[Seq[Stats]] = Future(appKit.nodeParams.db.audit.stats) + + /** + * Sends a request to a channel and expects a response + * + * @param channelIdentifier can be a shortChannelId (BOLT encoded) or a channelId (32-byte hex encoded) + * @param request + * @return + */ + def sendToChannel(channelIdentifier: String, request: Any): Future[Any] = + for { + fwdReq <- Future(Register.ForwardShortId(ShortChannelId(channelIdentifier), request)) + .recoverWith { case _ => Future(Register.Forward(ByteVector32.fromValidHex(channelIdentifier), request)) } + .recoverWith { case _ => Future.failed(new RuntimeException(s"invalid channel identifier '$channelIdentifier'")) } + res <- appKit.register ? fwdReq + } yield res + + override def getInfoResponse: Future[GetInfoResponse] = Future.successful( + GetInfoResponse(nodeId = appKit.nodeParams.nodeId, + alias = appKit.nodeParams.alias, + chainHash = appKit.nodeParams.chainHash, + blockHeight = Globals.blockCount.intValue(), + publicAddresses = appKit.nodeParams.publicAddresses) + ) + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index 3cbaecdbf..a27a5cab2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -31,7 +31,7 @@ import com.softwaremill.sttp.okhttp.OkHttpFutureBackend import com.typesafe.config.{Config, ConfigFactory} import fr.acinq.bitcoin.{Block, ByteVector32} import fr.acinq.eclair.NodeParams.{BITCOIND, ELECTRUM} -import fr.acinq.eclair.api.{GetInfoResponse, Service} +import fr.acinq.eclair.api._ import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BatchingBitcoinJsonRPCClient, ExtendedBitcoinClient} import fr.acinq.eclair.blockchain.bitcoind.zmq.ZMQActor import fr.acinq.eclair.blockchain.bitcoind.{BitcoinCoreWallet, ZmqWatcher} @@ -271,28 +271,32 @@ class Setup(datadir: File, _ <- if (config.getBoolean("api.enabled")) { logger.info(s"json-rpc api enabled on port=${config.getInt("api.port")}") implicit val materializer = ActorMaterializer() - val api = new Service { - - override def scheduler = system.scheduler - - override val password = { - val p = config.getString("api.password") - if (p.isEmpty) throw EmptyAPIPasswordException else p - } - - override def getInfoResponse: Future[GetInfoResponse] = Future.successful( - GetInfoResponse(nodeId = nodeParams.nodeId, - alias = nodeParams.alias, - port = config.getInt("server.port"), - chainHash = nodeParams.chainHash, - blockHeight = Globals.blockCount.intValue(), - publicAddresses = nodeParams.publicAddresses)) - - override def appKit: Kit = kit - - override val socketHandler = makeSocketHandler(system)(materializer) + val getInfo = GetInfoResponse(nodeId = nodeParams.nodeId, + alias = nodeParams.alias, + chainHash = nodeParams.chainHash, + blockHeight = Globals.blockCount.intValue(), + publicAddresses = nodeParams.publicAddresses) + val apiPassword = config.getString("api.password") match { + case "" => throw EmptyAPIPasswordException + case valid => valid } - val httpBound = Http().bindAndHandle(api.route, config.getString("api.binding-ip"), config.getInt("api.port")).recover { + val apiRoute = if (!config.getBoolean("api.use-old-api")) { + new Service { + override val actorSystem = kit.system + override val mat = materializer + override val password = apiPassword + override val eclairApi: Eclair = new EclairImpl(kit) + }.route + } else { + new OldService { + override val scheduler = system.scheduler + override val password = apiPassword + override val getInfoResponse: Future[GetInfoResponse] = Future.successful(getInfo) + override val appKit: Kit = kit + override val socketHandler = makeSocketHandler(system)(materializer) + }.route + } + val httpBound = Http().bindAndHandle(apiRoute, config.getString("api.binding-ip"), config.getInt("api.port")).recover { case _: BindFailedException => throw TCPBindException(config.getInt("api.port")) } val httpTimeout = after(5 seconds, using = system.scheduler)(Future.failed(TCPBindException(config.getInt("api.port")))) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/api/FormParamExtractors.scala b/eclair-core/src/main/scala/fr/acinq/eclair/api/FormParamExtractors.scala new file mode 100644 index 000000000..5e15506b8 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/api/FormParamExtractors.scala @@ -0,0 +1,32 @@ +package fr.acinq.eclair.api + +import akka.http.scaladsl.unmarshalling.Unmarshaller +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Crypto.PublicKey +import fr.acinq.eclair.ShortChannelId +import fr.acinq.eclair.payment.PaymentRequest +import scodec.bits.ByteVector + +object FormParamExtractors { + + implicit val publicKeyUnmarshaller: Unmarshaller[String, PublicKey] = Unmarshaller.strict { rawPubKey => + PublicKey(ByteVector.fromValidHex(rawPubKey)) + } + + implicit val binaryDataUnmarshaller: Unmarshaller[String, ByteVector] = Unmarshaller.strict { str => + ByteVector.fromValidHex(str) + } + + implicit val sha256HashUnmarshaller: Unmarshaller[String, ByteVector32] = Unmarshaller.strict { bin => + ByteVector32.fromValidHex(bin) + } + + implicit val bolt11Unmarshaller: Unmarshaller[String, PaymentRequest] = Unmarshaller.strict { rawRequest => + PaymentRequest.read(rawRequest) + } + + implicit val shortChannelIdUnmarshaller: Unmarshaller[String, ShortChannelId] = Unmarshaller.strict { str => + ShortChannelId(str) + } + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/api/JsonSerializers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/api/JsonSerializers.scala index eaffbe2db..347702301 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/api/JsonSerializers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/api/JsonSerializers.scala @@ -18,7 +18,11 @@ package fr.acinq.eclair.api import java.net.InetSocketAddress +import akka.http.scaladsl.model.MediaType +import akka.http.scaladsl.model.MediaTypes._ import com.google.common.net.HostAndPort +import de.heikoseeberger.akkahttpjson4s.Json4sSupport +import de.heikoseeberger.akkahttpjson4s.Json4sSupport.ShouldWritePretty import fr.acinq.bitcoin.Crypto.{Point, PrivateKey, PublicKey, Scalar} import fr.acinq.bitcoin.{ByteVector32, MilliSatoshi, OutPoint, Transaction} import fr.acinq.eclair.channel.State @@ -30,7 +34,7 @@ import fr.acinq.eclair.transactions.Transactions.{InputInfo, TransactionWithInpu import fr.acinq.eclair.wire._ import fr.acinq.eclair.{ShortChannelId, UInt64} import org.json4s.JsonAST._ -import org.json4s.{CustomKeySerializer, CustomSerializer} +import org.json4s.{CustomKeySerializer, CustomSerializer, jackson} import scodec.bits.ByteVector /** @@ -149,3 +153,37 @@ class PaymentRequestSerializer extends CustomSerializer[PaymentRequest](format = JField("minFinalCltvExpiry", if (p.minFinalCltvExpiry.isDefined) JLong(p.minFinalCltvExpiry.get) else JNull) :: Nil) })) + +object JsonSupport extends Json4sSupport { + + implicit val serialization = jackson.Serialization + + implicit val formats = org.json4s.DefaultFormats + + new ByteVectorSerializer + + new ByteVector32Serializer + + new UInt64Serializer + + new MilliSatoshiSerializer + + new ShortChannelIdSerializer + + new StateSerializer + + new ShaChainSerializer + + new PublicKeySerializer + + new PrivateKeySerializer + + new ScalarSerializer + + new PointSerializer + + new TransactionSerializer + + new TransactionWithInputInfoSerializer + + new InetSocketAddressSerializer + + new OutPointSerializer + + new OutPointKeySerializer + + new InputInfoSerializer + + new ColorSerializer + + new RouteResponseSerializer + + new ThrowableSerializer + + new FailureMessageSerializer + + new NodeAddressSerializer + + new DirectionSerializer + + new PaymentRequestSerializer + + implicit val shouldWritePretty: ShouldWritePretty = ShouldWritePretty.True + +} \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/api/OldService.scala b/eclair-core/src/main/scala/fr/acinq/eclair/api/OldService.scala new file mode 100644 index 000000000..13bb29889 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/api/OldService.scala @@ -0,0 +1,422 @@ +/* + * Copyright 2018 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.api + +import akka.NotUsed +import akka.actor.{Actor, ActorRef, ActorSystem, Props, Scheduler} +import akka.http.scaladsl.model.HttpMethods._ +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.headers.CacheDirectives.{`max-age`, `no-store`, public} +import akka.http.scaladsl.model.headers._ +import akka.http.scaladsl.model.ws.{Message, TextMessage} +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server._ +import akka.http.scaladsl.server.directives.Credentials +import akka.http.scaladsl.server.directives.RouteDirectives.reject +import akka.pattern.ask +import akka.stream.scaladsl.{BroadcastHub, Flow, Keep, Source} +import akka.stream.{ActorMaterializer, OverflowStrategy} +import akka.util.Timeout +import de.heikoseeberger.akkahttpjson4s.Json4sSupport +import de.heikoseeberger.akkahttpjson4s.Json4sSupport.ShouldWritePretty +import fr.acinq.bitcoin.Crypto.PublicKey +import fr.acinq.bitcoin.{ByteVector32, MilliSatoshi, Satoshi} +import fr.acinq.eclair.channel._ +import fr.acinq.eclair.io.Peer.{GetPeerInfo, PeerInfo} +import fr.acinq.eclair.io.{NodeURI, Peer} +import fr.acinq.eclair.payment.PaymentLifecycle._ +import fr.acinq.eclair.payment._ +import fr.acinq.eclair.router.{ChannelDesc, RouteRequest, RouteResponse} +import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement} +import fr.acinq.eclair.{Kit, ShortChannelId, feerateByte2Kw} +import grizzled.slf4j.Logging +import org.json4s.JsonAST.{JBool, JInt, JString} +import org.json4s.{JValue, jackson} +import scodec.bits.ByteVector + +import scala.concurrent.duration._ +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success, Try} + +// @formatter:off +case class JsonRPCBody(jsonrpc: String = "1.0", id: String = "eclair-node", method: String, params: Seq[JValue]) +case class Error(code: Int, message: String) +case class JsonRPCRes(result: AnyRef, error: Option[Error], id: String) +case class Status(node_id: String) +case class GetInfoResponse(nodeId: PublicKey, alias: String, chainHash: ByteVector32, blockHeight: Int, publicAddresses: Seq[NodeAddress]) +case class AuditResponse(sent: Seq[PaymentSent], received: Seq[PaymentReceived], relayed: Seq[PaymentRelayed]) +trait RPCRejection extends Rejection { + def requestId: String +} +final case class UnknownMethodRejection(requestId: String) extends RPCRejection +final case class UnknownParamsRejection(requestId: String, message: String) extends RPCRejection +final case class RpcValidationRejection(requestId: String, message: String) extends RPCRejection +final case class ExceptionRejection(requestId: String, message: String) extends RPCRejection +// @formatter:on + +trait OldService extends Logging { + + implicit def ec: ExecutionContext = ExecutionContext.Implicits.global + + def scheduler: Scheduler + + implicit val serialization = jackson.Serialization + implicit val formats = org.json4s.DefaultFormats + new ByteVectorSerializer + new ByteVector32Serializer + new UInt64Serializer + new MilliSatoshiSerializer + new ShortChannelIdSerializer + new StateSerializer + new ShaChainSerializer + new PublicKeySerializer + new PrivateKeySerializer + new ScalarSerializer + new PointSerializer + new TransactionSerializer + new TransactionWithInputInfoSerializer + new InetSocketAddressSerializer + new OutPointSerializer + new OutPointKeySerializer + new InputInfoSerializer + new ColorSerializer + new RouteResponseSerializer + new ThrowableSerializer + new FailureMessageSerializer + new NodeAddressSerializer + new DirectionSerializer +new PaymentRequestSerializer + implicit val timeout = Timeout(60 seconds) + implicit val shouldWritePretty: ShouldWritePretty = ShouldWritePretty.True + + import Json4sSupport.{marshaller, unmarshaller} + + def password: String + + def appKit: Kit + + val socketHandler: Flow[Message, TextMessage.Strict, NotUsed] + + def userPassAuthenticator(credentials: Credentials): Future[Option[String]] = credentials match { + case p@Credentials.Provided(id) if p.verify(password) => Future.successful(Some(id)) + case _ => akka.pattern.after(1 second, using = scheduler)(Future.successful(None)) // force a 1 sec pause to deter brute force + } + + val customHeaders = `Access-Control-Allow-Headers`("Content-Type, Authorization") :: + `Access-Control-Allow-Methods`(POST) :: + `Cache-Control`(public, `no-store`, `max-age`(0)) :: Nil + + val myExceptionHandler = ExceptionHandler { + case t: Throwable => + extractRequest { _ => + logger.error(s"API call failed with cause=${t.getMessage}") + complete(StatusCodes.InternalServerError, JsonRPCRes(null, Some(Error(StatusCodes.InternalServerError.intValue, t.getMessage)), "-1")) + } + } + + def completeRpcFuture(requestId: String, future: Future[AnyRef]): Route = onComplete(future) { + case Success(s) => completeRpc(requestId, s) + case Failure(t) => reject(ExceptionRejection(requestId, t.getLocalizedMessage)) + } + + def completeRpc(requestId: String, result: AnyRef): Route = complete(JsonRPCRes(result, None, requestId)) + + val myRejectionHandler: RejectionHandler = RejectionHandler.newBuilder() + .handleNotFound { + complete(StatusCodes.NotFound, JsonRPCRes(null, Some(Error(StatusCodes.NotFound.intValue, "not found")), "-1")) + } + .handle { + case _: AuthenticationFailedRejection ⇒ complete(StatusCodes.Unauthorized, JsonRPCRes(null, Some(Error(StatusCodes.Unauthorized.intValue, "Access restricted")), "-1")) + case v: RpcValidationRejection ⇒ complete(StatusCodes.BadRequest, JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, v.message)), v.requestId)) + case ukm: UnknownMethodRejection ⇒ complete(StatusCodes.BadRequest, JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, "method not found")), ukm.requestId)) + case p: UnknownParamsRejection ⇒ complete(StatusCodes.BadRequest, + JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, s"invalid parameters for this method, should be: ${p.message}")), p.requestId)) + case m: MalformedRequestContentRejection ⇒ complete(StatusCodes.BadRequest, + JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, s"malformed parameters for this method: ${m.message}")), "-1")) + case e: ExceptionRejection ⇒ complete(StatusCodes.BadRequest, + JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, s"command failed: ${e.message}")), e.requestId)) + case r ⇒ logger.error(s"API call failed with cause=$r") + complete(StatusCodes.BadRequest, JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, r.toString)), "-1")) + } + .result() + + val route: Route = + respondWithDefaultHeaders(customHeaders) { + withRequestTimeoutResponse(r => HttpResponse(StatusCodes.RequestTimeout).withEntity(ContentTypes.`application/json`, """{ "result": null, "error": { "code": 408, "message": "request timed out"} } """)) { + handleExceptions(myExceptionHandler) { + handleRejections(myRejectionHandler) { + authenticateBasicAsync(realm = "Access restricted", userPassAuthenticator) { _ => + pathSingleSlash { + post { + entity(as[JsonRPCBody]) { + req => + val kit = appKit + import kit._ + + req.method match { + // utility methods + case "getinfo" => completeRpcFuture(req.id, getInfoResponse) + case "help" => completeRpc(req.id, help) + + // channel lifecycle methods + case "connect" => req.params match { + case JString(pubkey) :: JString(host) :: JInt(port) :: Nil => + completeRpcFuture(req.id, (switchboard ? Peer.Connect(NodeURI.parse(s"$pubkey@$host:$port"))).mapTo[String]) + case JString(uri) :: Nil => + completeRpcFuture(req.id, (switchboard ? Peer.Connect(NodeURI.parse(uri))).mapTo[String]) + case _ => reject(UnknownParamsRejection(req.id, "[nodeId@host:port] or [nodeId, host, port]")) + } + case "open" => req.params match { + case JString(nodeId) :: JInt(fundingSatoshis) :: Nil => + completeRpcFuture(req.id, (switchboard ? Peer.OpenChannel(PublicKey(ByteVector.fromValidHex(nodeId)), Satoshi(fundingSatoshis.toLong), MilliSatoshi(0), fundingTxFeeratePerKw_opt = None, channelFlags = None)).mapTo[String]) + case JString(nodeId) :: JInt(fundingSatoshis) :: JInt(pushMsat) :: Nil => + completeRpcFuture(req.id, (switchboard ? Peer.OpenChannel(PublicKey(ByteVector.fromValidHex(nodeId)), Satoshi(fundingSatoshis.toLong), MilliSatoshi(pushMsat.toLong), channelFlags = None, fundingTxFeeratePerKw_opt = None)).mapTo[String]) + case JString(nodeId) :: JInt(fundingSatoshis) :: JInt(pushMsat) :: JInt(fundingFeerateSatPerByte) :: Nil => + completeRpcFuture(req.id, (switchboard ? Peer.OpenChannel(PublicKey(ByteVector.fromValidHex(nodeId)), Satoshi(fundingSatoshis.toLong), MilliSatoshi(pushMsat.toLong), fundingTxFeeratePerKw_opt = Some(feerateByte2Kw(fundingFeerateSatPerByte.toLong)), channelFlags = None)).mapTo[String]) + case JString(nodeId) :: JInt(fundingSatoshis) :: JInt(pushMsat) :: JInt(fundingFeerateSatPerByte) :: JInt(flags) :: Nil => + completeRpcFuture(req.id, (switchboard ? Peer.OpenChannel(PublicKey(ByteVector.fromValidHex(nodeId)), Satoshi(fundingSatoshis.toLong), MilliSatoshi(pushMsat.toLong), fundingTxFeeratePerKw_opt = Some(feerateByte2Kw(fundingFeerateSatPerByte.toLong)), channelFlags = Some(flags.toByte))).mapTo[String]) + case _ => reject(UnknownParamsRejection(req.id, s"[nodeId, fundingSatoshis], [nodeId, fundingSatoshis, pushMsat], [nodeId, fundingSatoshis, pushMsat, feerateSatPerByte] or [nodeId, fundingSatoshis, pushMsat, feerateSatPerByte, flag]")) + } + case "close" => req.params match { + case JString(identifier) :: Nil => completeRpcFuture(req.id, sendToChannel(identifier, CMD_CLOSE(scriptPubKey = None)).mapTo[String]) + case JString(identifier) :: JString(scriptPubKey) :: Nil => completeRpcFuture(req.id, sendToChannel(identifier, CMD_CLOSE(scriptPubKey = Some(ByteVector.fromValidHex(scriptPubKey)))).mapTo[String]) + case _ => reject(UnknownParamsRejection(req.id, "[channelId] or [channelId, scriptPubKey]")) + } + case "forceclose" => req.params match { + case JString(identifier) :: Nil => completeRpcFuture(req.id, sendToChannel(identifier, CMD_FORCECLOSE).mapTo[String]) + case _ => reject(UnknownParamsRejection(req.id, "[channelId]")) + } + case "updaterelayfee" => req.params match { + case JString(identifier) :: JInt(feeBaseMsat) :: JInt(feeProportionalMillionths) :: Nil => + completeRpcFuture(req.id, sendToChannel(identifier, CMD_UPDATE_RELAY_FEE(feeBaseMsat.toLong, feeProportionalMillionths.toLong)).mapTo[String]) + case JString(identifier) :: JString(feeBaseMsat) :: JString(feeProportionalMillionths) :: Nil => + completeRpcFuture(req.id, sendToChannel(identifier, CMD_UPDATE_RELAY_FEE(feeBaseMsat.toLong, feeProportionalMillionths.toLong)).mapTo[String]) + case _ => reject(UnknownParamsRejection(req.id, "[channelId] [feeBaseMsat] [feeProportionalMillionths]")) + } + // local network methods + case "peers" => completeRpcFuture(req.id, for { + peers <- (switchboard ? 'peers).mapTo[Iterable[ActorRef]] + peerinfos <- Future.sequence(peers.map(peer => (peer ? GetPeerInfo).mapTo[PeerInfo])) + } yield peerinfos) + case "channels" => req.params match { + case Nil => + val f = for { + channels_id <- (register ? 'channels).mapTo[Map[ByteVector32, ActorRef]].map(_.keys) + channels <- Future.sequence(channels_id.map(channel_id => sendToChannel(channel_id.toString(), CMD_GETINFO).mapTo[RES_GETINFO])) + } yield channels + completeRpcFuture(req.id, f) + case JString(remoteNodeId) :: Nil => Try(PublicKey(ByteVector.fromValidHex(remoteNodeId))) match { + case Success(pk) => + val f = for { + channels_id <- (register ? 'channelsTo).mapTo[Map[ByteVector32, PublicKey]].map(_.filter(_._2 == pk).keys) + channels <- Future.sequence(channels_id.map(channel_id => sendToChannel(channel_id.toString(), CMD_GETINFO).mapTo[RES_GETINFO])) + } yield channels + completeRpcFuture(req.id, f) + case Failure(_) => reject(RpcValidationRejection(req.id, s"invalid remote node id '$remoteNodeId'")) + } + case _ => reject(UnknownParamsRejection(req.id, "no arguments or [remoteNodeId]")) + } + case "channel" => req.params match { + case JString(identifier) :: Nil => completeRpcFuture(req.id, sendToChannel(identifier, CMD_GETINFO).mapTo[RES_GETINFO]) + case _ => reject(UnknownParamsRejection(req.id, "[channelId]")) + } + + // global network methods + case "allnodes" => completeRpcFuture(req.id, (router ? 'nodes).mapTo[Iterable[NodeAnnouncement]]) + case "allchannels" => completeRpcFuture(req.id, (router ? 'channels).mapTo[Iterable[ChannelAnnouncement]].map(_.map(c => ChannelDesc(c.shortChannelId, c.nodeId1, c.nodeId2)))) + case "allupdates" => req.params match { + case JString(nodeId) :: Nil => Try(PublicKey(ByteVector.fromValidHex(nodeId))) match { + case Success(pk) => completeRpcFuture(req.id, (router ? 'updatesMap).mapTo[Map[ChannelDesc, ChannelUpdate]].map(_.filter(e => e._1.a == pk || e._1.b == pk).values)) + case Failure(_) => reject(RpcValidationRejection(req.id, s"invalid remote node id '$nodeId'")) + } + case _ => completeRpcFuture(req.id, (router ? 'updates).mapTo[Iterable[ChannelUpdate]]) + } + + // payment methods + case "receive" => req.params match { + // only the payment description is given: user may want to generate a donation payment request + case JString(description) :: Nil => + completeRpcFuture(req.id, (paymentHandler ? ReceivePayment(None, description)).mapTo[PaymentRequest].map(PaymentRequest.write)) + // the amount is now given with the description + case JInt(amountMsat) :: JString(description) :: Nil => + completeRpcFuture(req.id, (paymentHandler ? ReceivePayment(Some(MilliSatoshi(amountMsat.toLong)), description)).mapTo[PaymentRequest].map(PaymentRequest.write)) + case JInt(amountMsat) :: JString(description) :: JInt(expirySeconds) :: Nil => + completeRpcFuture(req.id, (paymentHandler ? ReceivePayment(Some(MilliSatoshi(amountMsat.toLong)), description, Some(expirySeconds.toLong))).mapTo[PaymentRequest].map(PaymentRequest.write)) + case _ => reject(UnknownParamsRejection(req.id, "[description] or [amount, description] or [amount, description, expiryDuration]")) + } + + // checkinvoice deprecated. + case "parseinvoice" | "checkinvoice" => req.params match { + case JString(paymentRequest) :: Nil => Try(PaymentRequest.read(paymentRequest)) match { + case Success(pr) => completeRpc(req.id,pr) + case Failure(t) => reject(RpcValidationRejection(req.id, s"invalid payment request ${t.getMessage}")) + } + case _ => reject(UnknownParamsRejection(req.id, "[payment_request]")) + } + + case "findroute" => req.params match { + case JString(nodeId) :: JInt(amountMsat) :: Nil if nodeId.length() == 66 => Try(PublicKey(ByteVector.fromValidHex(nodeId))) match { + case Success(pk) => completeRpcFuture(req.id, (router ? RouteRequest(appKit.nodeParams.nodeId, pk, amountMsat.toLong)).mapTo[RouteResponse]) + case Failure(_) => reject(RpcValidationRejection(req.id, s"invalid nodeId hash '$nodeId'")) + } + case JString(paymentRequest) :: Nil => Try(PaymentRequest.read(paymentRequest)) match { + case Success(PaymentRequest(_, Some(amountMsat), _, nodeId , _, _)) => completeRpcFuture(req.id, (router ? RouteRequest(appKit.nodeParams.nodeId, nodeId, amountMsat.toLong)).mapTo[RouteResponse]) + case Success(_) => reject(RpcValidationRejection(req.id, s"payment request is missing amount, please specify it")) + case Failure(t) => reject(RpcValidationRejection(req.id, s"invalid payment request ${t.getLocalizedMessage}")) + } + case JString(paymentRequest) :: JInt(amountMsat) :: Nil => Try(PaymentRequest.read(paymentRequest)) match { + case Success(PaymentRequest(_, None, _, nodeId , _, _)) => completeRpcFuture(req.id, (router ? RouteRequest(appKit.nodeParams.nodeId, nodeId, amountMsat.toLong)).mapTo[RouteResponse]) + case Success(_) => reject(RpcValidationRejection(req.id, s"amount was specified both in payment request and api call")) + case Failure(t) => reject(RpcValidationRejection(req.id, s"invalid payment request ${t.getLocalizedMessage}")) + } + case _ => reject(UnknownParamsRejection(req.id, "[payment_request] or [payment_request, amountMsat] or [nodeId, amountMsat]")) + } + + case "send" => req.params match { + // user manually sets the payment information + case JInt(amountMsat) :: JString(paymentHash) :: JString(nodeId) :: Nil => + (Try(ByteVector32.fromValidHex(paymentHash)), Try(PublicKey(ByteVector.fromValidHex(nodeId)))) match { + case (Success(ph), Success(pk)) => completeRpcFuture(req.id, (paymentInitiator ? + SendPayment(amountMsat.toLong, ph, pk)).mapTo[PaymentResult].map { + case s: PaymentSucceeded => s + case f: PaymentFailed => f.copy(failures = PaymentLifecycle.transformForUser(f.failures)) + }) + case (Failure(_), _) => reject(RpcValidationRejection(req.id, s"invalid payment hash '$paymentHash'")) + case _ => reject(RpcValidationRejection(req.id, s"invalid node id '$nodeId'")) + } + // user gives a Lightning payment request + case JString(paymentRequest) :: rest => Try(PaymentRequest.read(paymentRequest)) match { + case Success(pr) => + // setting the payment amount + val amount_msat: Long = (pr.amount, rest) match { + // optional amount always overrides the amount in the payment request + case (_, JInt(amount_msat_override) :: Nil) => amount_msat_override.toLong + case (Some(amount_msat_pr), _) => amount_msat_pr.amount + case _ => throw new RuntimeException("you must manually specify an amount for this payment request") + } + logger.debug(s"api call for sending payment with amount_msat=$amount_msat") + // optional cltv expiry + val sendPayment = pr.minFinalCltvExpiry match { + case None => SendPayment(amount_msat, pr.paymentHash, pr.nodeId) + case Some(minFinalCltvExpiry) => SendPayment(amount_msat, pr.paymentHash, pr.nodeId, assistedRoutes = Nil, minFinalCltvExpiry) + } + completeRpcFuture(req.id, (paymentInitiator ? sendPayment).mapTo[PaymentResult].map { + case s: PaymentSucceeded => s + case f: PaymentFailed => f.copy(failures = PaymentLifecycle.transformForUser(f.failures)) + }) + case _ => reject(RpcValidationRejection(req.id, s"payment request is not valid")) + } + case _ => reject(UnknownParamsRejection(req.id, "[amountMsat, paymentHash, nodeId or [paymentRequest] or [paymentRequest, amountMsat]")) + } + + // check received payments + case "checkpayment" => req.params match { + case JString(identifier) :: Nil => completeRpcFuture(req.id, for { + paymentHash <- Try(PaymentRequest.read(identifier)) match { + case Success(pr) => Future.successful(pr.paymentHash) + case _ => Try(ByteVector.fromValidHex(identifier)) match { + case Success(s) => Future.successful(s) + case _ => Future.failed(new IllegalArgumentException("payment identifier must be a payment request or a payment hash")) + } + } + found <- (paymentHandler ? CheckPayment(ByteVector32.fromValidHex(identifier))).map(found => new JBool(found.asInstanceOf[Boolean])) + } yield found) + case _ => reject(UnknownParamsRejection(req.id, "[paymentHash] or [paymentRequest]")) + } + + // retrieve audit events + case "audit" => + val (from, to) = req.params match { + case JInt(from) :: JInt(to) :: Nil => (from.toLong, to.toLong) + case _ => (0L, Long.MaxValue) + } + completeRpcFuture(req.id, Future(AuditResponse( + sent = nodeParams.db.audit.listSent(from, to), + received = nodeParams.db.audit.listReceived(from, to), + relayed = nodeParams.db.audit.listRelayed(from, to)) + )) + + case "networkfees" => + val (from, to) = req.params match { + case JInt(from) :: JInt(to) :: Nil => (from.toLong, to.toLong) + case _ => (0L, Long.MaxValue) + } + completeRpcFuture(req.id, Future(nodeParams.db.audit.listNetworkFees(from, to))) + + // retrieve fee stats + case "channelstats" => completeRpcFuture(req.id, Future(nodeParams.db.audit.stats)) + + + // method name was not found + case _ => reject(UnknownMethodRejection(req.id)) + } + } + } + } + } ~ path("ws") { + handleWebSocketMessages(socketHandler) + } + } + } + } + } + + def getInfoResponse: Future[GetInfoResponse] + + def makeSocketHandler(system: ActorSystem)(implicit materializer: ActorMaterializer): Flow[Message, TextMessage.Strict, NotUsed] = { + + // create a flow transforming a queue of string -> string + val (flowInput, flowOutput) = Source.queue[String](10, OverflowStrategy.dropTail).toMat(BroadcastHub.sink[String])(Keep.both).run() + + // register an actor that feeds the queue when a payment is received + system.actorOf(Props(new Actor { + override def preStart: Unit = context.system.eventStream.subscribe(self, classOf[PaymentReceived]) + def receive: Receive = { case received: PaymentReceived => flowInput.offer(received.paymentHash.toString) } + })) + + Flow[Message] + .mapConcat(_ => Nil) // Ignore heartbeats and other data from the client + .merge(flowOutput) // Stream the data we want to the client + .map(TextMessage.apply) + } + + def help = List( + "connect (uri): open a secure connection to a lightning node", + "connect (nodeId, host, port): open a secure connection to a lightning node", + "open (nodeId, fundingSatoshis, pushMsat = 0, feerateSatPerByte = ?, channelFlags = 0x01): open a channel with another lightning node, by default push = 0, feerate for the funding tx targets 6 blocks, and channel is announced", + "updaterelayfee (channelId, feeBaseMsat, feeProportionalMillionths): update relay fee for payments going through this channel", + "peers: list existing local peers", + "channels: list existing local channels", + "channels (nodeId): list existing local channels to a particular nodeId", + "channel (channelId): retrieve detailed information about a given channel", + "channelstats: retrieves statistics about channel usage (fees, number and average amount of payments)", + "allnodes: list all known nodes", + "allchannels: list all known channels", + "allupdates: list all channels updates", + "allupdates (nodeId): list all channels updates for this nodeId", + "receive (amountMsat, description): generate a payment request for a given amount", + "receive (amountMsat, description, expirySeconds): generate a payment request for a given amount with a description and a number of seconds till it expires", + "parseinvoice (paymentRequest): returns node, amount and payment hash in a payment request", + "findroute (paymentRequest): returns nodes and channels of the route if there is any", + "findroute (paymentRequest, amountMsat): returns nodes and channels of the route if there is any", + "findroute (nodeId, amountMsat): returns nodes and channels of the route if there is any", + "send (amountMsat, paymentHash, nodeId): send a payment to a lightning node", + "send (paymentRequest): send a payment to a lightning node using a BOLT11 payment request", + "send (paymentRequest, amountMsat): send a payment to a lightning node using a BOLT11 payment request and a custom amount", + "close (channelId): close a channel", + "close (channelId, scriptPubKey): close a channel and send the funds to the given scriptPubKey", + "forceclose (channelId): force-close a channel by publishing the local commitment tx (careful: this is more expensive than a regular close and will incur a delay before funds are spendable)", + "checkpayment (paymentHash): returns true if the payment has been received, false otherwise", + "checkpayment (paymentRequest): returns true if the payment has been received, false otherwise", + "audit: list all send/received/relayed payments", + "audit (from, to): list send/received/relayed payments in that interval (from <= timestamp < to)", + "networkfees: list all network fees paid to the miners, by transaction", + "networkfees (from, to): list network fees paid to the miners, by transaction, in that interval (from <= timestamp < to)", + "getinfo: returns info about the blockchain and this node", + "help: display this message") + + /** + * Sends a request to a channel and expects a response + * + * @param channelIdentifier can be a shortChannelId (BOLT encoded) or a channelId (32-byte hex encoded) + * @param request + * @return + */ + def sendToChannel(channelIdentifier: String, request: Any): Future[Any] = + for { + fwdReq <- Future(Register.ForwardShortId(ShortChannelId(channelIdentifier), request)) + .recoverWith { case _ => Future(Register.Forward(ByteVector32.fromValidHex(channelIdentifier), request)) } + .recoverWith { case _ => Future.failed(new RuntimeException(s"invalid channel identifier '$channelIdentifier'")) } + res <- appKit.register ? fwdReq + } yield res +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala b/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala index 69a078bb1..1674096fb 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala @@ -1,367 +1,76 @@ -/* - * Copyright 2018 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - package fr.acinq.eclair.api -import akka.NotUsed -import akka.actor.{Actor, ActorRef, ActorSystem, Props, Scheduler} -import akka.http.scaladsl.model.HttpMethods._ -import akka.http.scaladsl.model._ -import akka.http.scaladsl.model.headers.CacheDirectives.{`max-age`, `no-store`, public} -import akka.http.scaladsl.model.headers._ -import akka.http.scaladsl.model.ws.{Message, TextMessage} -import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server._ -import akka.http.scaladsl.server.directives.Credentials -import akka.http.scaladsl.server.directives.RouteDirectives.reject -import akka.pattern.ask -import akka.stream.scaladsl.{BroadcastHub, Flow, Keep, Source} -import akka.stream.{ActorMaterializer, OverflowStrategy} -import akka.util.Timeout -import de.heikoseeberger.akkahttpjson4s.Json4sSupport -import de.heikoseeberger.akkahttpjson4s.Json4sSupport.ShouldWritePretty import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.bitcoin.{ByteVector32, MilliSatoshi, Satoshi} -import fr.acinq.eclair.channel._ -import fr.acinq.eclair.io.Peer.{GetPeerInfo, PeerInfo} -import fr.acinq.eclair.io.{NodeURI, Peer} -import fr.acinq.eclair.payment.PaymentLifecycle._ -import fr.acinq.eclair.payment._ -import fr.acinq.eclair.router.{ChannelDesc, RouteRequest, RouteResponse} -import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement} -import fr.acinq.eclair.{Kit, ShortChannelId, feerateByte2Kw} +import fr.acinq.eclair.{Eclair, Kit, ShortChannelId} +import FormParamExtractors._ +import akka.NotUsed +import akka.actor.{Actor, ActorRef, ActorSystem, Props} +import akka.http.scaladsl.model.HttpMethods.POST +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.headers.CacheDirectives.{`max-age`, `no-store`, public} +import akka.http.scaladsl.model.headers.{`Access-Control-Allow-Headers`, `Access-Control-Allow-Methods`, `Cache-Control`} +import akka.http.scaladsl.model.ws.{Message, TextMessage} +import akka.http.scaladsl.server.directives.{Credentials, LoggingMagnet} +import akka.stream.{ActorMaterializer, OverflowStrategy} +import akka.stream.scaladsl.{BroadcastHub, Flow, Keep, Source} +import fr.acinq.eclair.io.NodeURI +import fr.acinq.eclair.payment.{PaymentLifecycle, PaymentReceived, PaymentRequest} import grizzled.slf4j.Logging -import org.json4s.JsonAST.{JBool, JInt, JString} -import org.json4s.{JValue, jackson} import scodec.bits.ByteVector -import scala.concurrent.duration._ import scala.concurrent.{ExecutionContext, Future} -import scala.util.{Failure, Success, Try} +import scala.concurrent.duration._ -// @formatter:off -case class JsonRPCBody(jsonrpc: String = "1.0", id: String = "eclair-node", method: String, params: Seq[JValue]) -case class Error(code: Int, message: String) -case class JsonRPCRes(result: AnyRef, error: Option[Error], id: String) -case class Status(node_id: String) -case class GetInfoResponse(nodeId: PublicKey, alias: String, port: Int, chainHash: ByteVector32, blockHeight: Int, publicAddresses: Seq[NodeAddress]) -case class AuditResponse(sent: Seq[PaymentSent], received: Seq[PaymentReceived], relayed: Seq[PaymentRelayed]) -trait RPCRejection extends Rejection { - def requestId: String -} -final case class UnknownMethodRejection(requestId: String) extends RPCRejection -final case class UnknownParamsRejection(requestId: String, message: String) extends RPCRejection -final case class RpcValidationRejection(requestId: String, message: String) extends RPCRejection -final case class ExceptionRejection(requestId: String, message: String) extends RPCRejection -// @formatter:on +case class ErrorResponse(error: String) -trait Service extends Logging { +trait Service extends Directives with Logging { - implicit def ec: ExecutionContext = ExecutionContext.Implicits.global - - def scheduler: Scheduler - - implicit val serialization = jackson.Serialization - implicit val formats = org.json4s.DefaultFormats + new ByteVectorSerializer + new ByteVector32Serializer + new UInt64Serializer + new MilliSatoshiSerializer + new ShortChannelIdSerializer + new StateSerializer + new ShaChainSerializer + new PublicKeySerializer + new PrivateKeySerializer + new ScalarSerializer + new PointSerializer + new TransactionSerializer + new TransactionWithInputInfoSerializer + new InetSocketAddressSerializer + new OutPointSerializer + new OutPointKeySerializer + new InputInfoSerializer + new ColorSerializer + new RouteResponseSerializer + new ThrowableSerializer + new FailureMessageSerializer + new NodeAddressSerializer + new DirectionSerializer +new PaymentRequestSerializer - implicit val timeout = Timeout(60 seconds) - implicit val shouldWritePretty: ShouldWritePretty = ShouldWritePretty.True - - import Json4sSupport.{marshaller, unmarshaller} + // important! Must NOT import the unmarshaller as it is too generic...see https://github.com/akka/akka-http/issues/541 + import JsonSupport.marshaller + import JsonSupport.formats + import JsonSupport.serialization def password: String - def appKit: Kit + val eclairApi: Eclair - val socketHandler: Flow[Message, TextMessage.Strict, NotUsed] + implicit val actorSystem: ActorSystem + implicit val mat: ActorMaterializer - def userPassAuthenticator(credentials: Credentials): Future[Option[String]] = credentials match { - case p@Credentials.Provided(id) if p.verify(password) => Future.successful(Some(id)) - case _ => akka.pattern.after(1 second, using = scheduler)(Future.successful(None)) // force a 1 sec pause to deter brute force + // a named and typed URL parameter used across several routes, 32-bytes hex-encoded + val channelId = "channelId".as[ByteVector32](sha256HashUnmarshaller) + val shortChannelId = "shortChannelId".as[ShortChannelId](shortChannelIdUnmarshaller) + + val apiExceptionHandler = ExceptionHandler { + case t: Throwable => + logger.error(s"API call failed with cause=${t.getMessage}", t) + complete(StatusCodes.InternalServerError, ErrorResponse(t.getMessage)) + } + + // map all the rejections to a JSON error object ErrorResponse + val apiRejectionHandler = RejectionHandler.default.mapRejectionResponse { + case res @ HttpResponse(_, _, ent: HttpEntity.Strict, _) => + res.copy(entity = HttpEntity(ContentTypes.`application/json`, serialization.writePretty(ErrorResponse(ent.data.utf8String)))) } val customHeaders = `Access-Control-Allow-Headers`("Content-Type, Authorization") :: `Access-Control-Allow-Methods`(POST) :: `Cache-Control`(public, `no-store`, `max-age`(0)) :: Nil - val myExceptionHandler = ExceptionHandler { - case t: Throwable => - extractRequest { _ => - logger.error(s"API call failed with cause=${t.getMessage}") - complete(StatusCodes.InternalServerError, JsonRPCRes(null, Some(Error(StatusCodes.InternalServerError.intValue, t.getMessage)), "-1")) - } - } - - def completeRpcFuture(requestId: String, future: Future[AnyRef]): Route = onComplete(future) { - case Success(s) => completeRpc(requestId, s) - case Failure(t) => reject(ExceptionRejection(requestId, t.getLocalizedMessage)) - } - - def completeRpc(requestId: String, result: AnyRef): Route = complete(JsonRPCRes(result, None, requestId)) - - val myRejectionHandler: RejectionHandler = RejectionHandler.newBuilder() - .handleNotFound { - complete(StatusCodes.NotFound, JsonRPCRes(null, Some(Error(StatusCodes.NotFound.intValue, "not found")), "-1")) - } - .handle { - case _: AuthenticationFailedRejection ⇒ complete(StatusCodes.Unauthorized, JsonRPCRes(null, Some(Error(StatusCodes.Unauthorized.intValue, "Access restricted")), "-1")) - case v: RpcValidationRejection ⇒ complete(StatusCodes.BadRequest, JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, v.message)), v.requestId)) - case ukm: UnknownMethodRejection ⇒ complete(StatusCodes.BadRequest, JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, "method not found")), ukm.requestId)) - case p: UnknownParamsRejection ⇒ complete(StatusCodes.BadRequest, - JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, s"invalid parameters for this method, should be: ${p.message}")), p.requestId)) - case m: MalformedRequestContentRejection ⇒ complete(StatusCodes.BadRequest, - JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, s"malformed parameters for this method: ${m.message}")), "-1")) - case e: ExceptionRejection ⇒ complete(StatusCodes.BadRequest, - JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, s"command failed: ${e.message}")), e.requestId)) - case r ⇒ logger.error(s"API call failed with cause=$r") - complete(StatusCodes.BadRequest, JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, r.toString)), "-1")) - } - .result() - - val route: Route = - respondWithDefaultHeaders(customHeaders) { - withRequestTimeoutResponse(r => HttpResponse(StatusCodes.RequestTimeout).withEntity(ContentTypes.`application/json`, """{ "result": null, "error": { "code": 408, "message": "request timed out"} } """)) { - handleExceptions(myExceptionHandler) { - handleRejections(myRejectionHandler) { - authenticateBasicAsync(realm = "Access restricted", userPassAuthenticator) { _ => - pathSingleSlash { - post { - entity(as[JsonRPCBody]) { - req => - val kit = appKit - import kit._ - - req.method match { - // utility methods - case "getinfo" => completeRpcFuture(req.id, getInfoResponse) - case "help" => completeRpc(req.id, help) - - // channel lifecycle methods - case "connect" => req.params match { - case JString(pubkey) :: JString(host) :: JInt(port) :: Nil => - completeRpcFuture(req.id, (switchboard ? Peer.Connect(NodeURI.parse(s"$pubkey@$host:$port"))).mapTo[String]) - case JString(uri) :: Nil => - completeRpcFuture(req.id, (switchboard ? Peer.Connect(NodeURI.parse(uri))).mapTo[String]) - case _ => reject(UnknownParamsRejection(req.id, "[nodeId@host:port] or [nodeId, host, port]")) - } - case "open" => req.params match { - case JString(nodeId) :: JInt(fundingSatoshis) :: Nil => - completeRpcFuture(req.id, (switchboard ? Peer.OpenChannel(PublicKey(ByteVector.fromValidHex(nodeId)), Satoshi(fundingSatoshis.toLong), MilliSatoshi(0), fundingTxFeeratePerKw_opt = None, channelFlags = None)).mapTo[String]) - case JString(nodeId) :: JInt(fundingSatoshis) :: JInt(pushMsat) :: Nil => - completeRpcFuture(req.id, (switchboard ? Peer.OpenChannel(PublicKey(ByteVector.fromValidHex(nodeId)), Satoshi(fundingSatoshis.toLong), MilliSatoshi(pushMsat.toLong), channelFlags = None, fundingTxFeeratePerKw_opt = None)).mapTo[String]) - case JString(nodeId) :: JInt(fundingSatoshis) :: JInt(pushMsat) :: JInt(fundingFeerateSatPerByte) :: Nil => - completeRpcFuture(req.id, (switchboard ? Peer.OpenChannel(PublicKey(ByteVector.fromValidHex(nodeId)), Satoshi(fundingSatoshis.toLong), MilliSatoshi(pushMsat.toLong), fundingTxFeeratePerKw_opt = Some(feerateByte2Kw(fundingFeerateSatPerByte.toLong)), channelFlags = None)).mapTo[String]) - case JString(nodeId) :: JInt(fundingSatoshis) :: JInt(pushMsat) :: JInt(fundingFeerateSatPerByte) :: JInt(flags) :: Nil => - completeRpcFuture(req.id, (switchboard ? Peer.OpenChannel(PublicKey(ByteVector.fromValidHex(nodeId)), Satoshi(fundingSatoshis.toLong), MilliSatoshi(pushMsat.toLong), fundingTxFeeratePerKw_opt = Some(feerateByte2Kw(fundingFeerateSatPerByte.toLong)), channelFlags = Some(flags.toByte))).mapTo[String]) - case _ => reject(UnknownParamsRejection(req.id, s"[nodeId, fundingSatoshis], [nodeId, fundingSatoshis, pushMsat], [nodeId, fundingSatoshis, pushMsat, feerateSatPerByte] or [nodeId, fundingSatoshis, pushMsat, feerateSatPerByte, flag]")) - } - case "close" => req.params match { - case JString(identifier) :: Nil => completeRpcFuture(req.id, sendToChannel(identifier, CMD_CLOSE(scriptPubKey = None)).mapTo[String]) - case JString(identifier) :: JString(scriptPubKey) :: Nil => completeRpcFuture(req.id, sendToChannel(identifier, CMD_CLOSE(scriptPubKey = Some(ByteVector.fromValidHex(scriptPubKey)))).mapTo[String]) - case _ => reject(UnknownParamsRejection(req.id, "[channelId] or [channelId, scriptPubKey]")) - } - case "forceclose" => req.params match { - case JString(identifier) :: Nil => completeRpcFuture(req.id, sendToChannel(identifier, CMD_FORCECLOSE).mapTo[String]) - case _ => reject(UnknownParamsRejection(req.id, "[channelId]")) - } - case "updaterelayfee" => req.params match { - case JString(identifier) :: JInt(feeBaseMsat) :: JInt(feeProportionalMillionths) :: Nil => - completeRpcFuture(req.id, sendToChannel(identifier, CMD_UPDATE_RELAY_FEE(feeBaseMsat.toLong, feeProportionalMillionths.toLong)).mapTo[String]) - case JString(identifier) :: JString(feeBaseMsat) :: JString(feeProportionalMillionths) :: Nil => - completeRpcFuture(req.id, sendToChannel(identifier, CMD_UPDATE_RELAY_FEE(feeBaseMsat.toLong, feeProportionalMillionths.toLong)).mapTo[String]) - case _ => reject(UnknownParamsRejection(req.id, "[channelId] [feeBaseMsat] [feeProportionalMillionths]")) - } - // local network methods - case "peers" => completeRpcFuture(req.id, for { - peers <- (switchboard ? 'peers).mapTo[Iterable[ActorRef]] - peerinfos <- Future.sequence(peers.map(peer => (peer ? GetPeerInfo).mapTo[PeerInfo])) - } yield peerinfos) - case "channels" => req.params match { - case Nil => - val f = for { - channels_id <- (register ? 'channels).mapTo[Map[ByteVector32, ActorRef]].map(_.keys) - channels <- Future.sequence(channels_id.map(channel_id => sendToChannel(channel_id.toString(), CMD_GETINFO).mapTo[RES_GETINFO])) - } yield channels - completeRpcFuture(req.id, f) - case JString(remoteNodeId) :: Nil => Try(PublicKey(ByteVector.fromValidHex(remoteNodeId))) match { - case Success(pk) => - val f = for { - channels_id <- (register ? 'channelsTo).mapTo[Map[ByteVector32, PublicKey]].map(_.filter(_._2 == pk).keys) - channels <- Future.sequence(channels_id.map(channel_id => sendToChannel(channel_id.toString(), CMD_GETINFO).mapTo[RES_GETINFO])) - } yield channels - completeRpcFuture(req.id, f) - case Failure(_) => reject(RpcValidationRejection(req.id, s"invalid remote node id '$remoteNodeId'")) - } - case _ => reject(UnknownParamsRejection(req.id, "no arguments or [remoteNodeId]")) - } - case "channel" => req.params match { - case JString(identifier) :: Nil => completeRpcFuture(req.id, sendToChannel(identifier, CMD_GETINFO).mapTo[RES_GETINFO]) - case _ => reject(UnknownParamsRejection(req.id, "[channelId]")) - } - - // global network methods - case "allnodes" => completeRpcFuture(req.id, (router ? 'nodes).mapTo[Iterable[NodeAnnouncement]]) - case "allchannels" => completeRpcFuture(req.id, (router ? 'channels).mapTo[Iterable[ChannelAnnouncement]].map(_.map(c => ChannelDesc(c.shortChannelId, c.nodeId1, c.nodeId2)))) - case "allupdates" => req.params match { - case JString(nodeId) :: Nil => Try(PublicKey(ByteVector.fromValidHex(nodeId))) match { - case Success(pk) => completeRpcFuture(req.id, (router ? 'updatesMap).mapTo[Map[ChannelDesc, ChannelUpdate]].map(_.filter(e => e._1.a == pk || e._1.b == pk).values)) - case Failure(_) => reject(RpcValidationRejection(req.id, s"invalid remote node id '$nodeId'")) - } - case _ => completeRpcFuture(req.id, (router ? 'updates).mapTo[Iterable[ChannelUpdate]]) - } - - // payment methods - case "receive" => req.params match { - // only the payment description is given: user may want to generate a donation payment request - case JString(description) :: Nil => - completeRpcFuture(req.id, (paymentHandler ? ReceivePayment(None, description)).mapTo[PaymentRequest].map(PaymentRequest.write)) - // the amount is now given with the description - case JInt(amountMsat) :: JString(description) :: Nil => - completeRpcFuture(req.id, (paymentHandler ? ReceivePayment(Some(MilliSatoshi(amountMsat.toLong)), description)).mapTo[PaymentRequest].map(PaymentRequest.write)) - case JInt(amountMsat) :: JString(description) :: JInt(expirySeconds) :: Nil => - completeRpcFuture(req.id, (paymentHandler ? ReceivePayment(Some(MilliSatoshi(amountMsat.toLong)), description, Some(expirySeconds.toLong))).mapTo[PaymentRequest].map(PaymentRequest.write)) - case _ => reject(UnknownParamsRejection(req.id, "[description] or [amount, description] or [amount, description, expiryDuration]")) - } - - // checkinvoice deprecated. - case "parseinvoice" | "checkinvoice" => req.params match { - case JString(paymentRequest) :: Nil => Try(PaymentRequest.read(paymentRequest)) match { - case Success(pr) => completeRpc(req.id,pr) - case Failure(t) => reject(RpcValidationRejection(req.id, s"invalid payment request ${t.getMessage}")) - } - case _ => reject(UnknownParamsRejection(req.id, "[payment_request]")) - } - - case "findroute" => req.params match { - case JString(nodeId) :: JInt(amountMsat) :: Nil if nodeId.length() == 66 => Try(PublicKey(ByteVector.fromValidHex(nodeId))) match { - case Success(pk) => completeRpcFuture(req.id, (router ? RouteRequest(appKit.nodeParams.nodeId, pk, amountMsat.toLong)).mapTo[RouteResponse]) - case Failure(_) => reject(RpcValidationRejection(req.id, s"invalid nodeId hash '$nodeId'")) - } - case JString(paymentRequest) :: Nil => Try(PaymentRequest.read(paymentRequest)) match { - case Success(PaymentRequest(_, Some(amountMsat), _, nodeId , _, _)) => completeRpcFuture(req.id, (router ? RouteRequest(appKit.nodeParams.nodeId, nodeId, amountMsat.toLong)).mapTo[RouteResponse]) - case Success(_) => reject(RpcValidationRejection(req.id, s"payment request is missing amount, please specify it")) - case Failure(t) => reject(RpcValidationRejection(req.id, s"invalid payment request ${t.getLocalizedMessage}")) - } - case JString(paymentRequest) :: JInt(amountMsat) :: Nil => Try(PaymentRequest.read(paymentRequest)) match { - case Success(PaymentRequest(_, None, _, nodeId , _, _)) => completeRpcFuture(req.id, (router ? RouteRequest(appKit.nodeParams.nodeId, nodeId, amountMsat.toLong)).mapTo[RouteResponse]) - case Success(_) => reject(RpcValidationRejection(req.id, s"amount was specified both in payment request and api call")) - case Failure(t) => reject(RpcValidationRejection(req.id, s"invalid payment request ${t.getLocalizedMessage}")) - } - case _ => reject(UnknownParamsRejection(req.id, "[payment_request] or [payment_request, amountMsat] or [nodeId, amountMsat]")) - } - - case "send" => req.params match { - // user manually sets the payment information - case JInt(amountMsat) :: JString(paymentHash) :: JString(nodeId) :: Nil => - (Try(ByteVector32.fromValidHex(paymentHash)), Try(PublicKey(ByteVector.fromValidHex(nodeId)))) match { - case (Success(ph), Success(pk)) => completeRpcFuture(req.id, (paymentInitiator ? - SendPayment(amountMsat.toLong, ph, pk)).mapTo[PaymentResult].map { - case s: PaymentSucceeded => s - case f: PaymentFailed => f.copy(failures = PaymentLifecycle.transformForUser(f.failures)) - }) - case (Failure(_), _) => reject(RpcValidationRejection(req.id, s"invalid payment hash '$paymentHash'")) - case _ => reject(RpcValidationRejection(req.id, s"invalid node id '$nodeId'")) - } - // user gives a Lightning payment request - case JString(paymentRequest) :: rest => Try(PaymentRequest.read(paymentRequest)) match { - case Success(pr) => - // setting the payment amount - val amount_msat: Long = (pr.amount, rest) match { - // optional amount always overrides the amount in the payment request - case (_, JInt(amount_msat_override) :: Nil) => amount_msat_override.toLong - case (Some(amount_msat_pr), _) => amount_msat_pr.amount - case _ => throw new RuntimeException("you must manually specify an amount for this payment request") - } - logger.debug(s"api call for sending payment with amount_msat=$amount_msat") - // optional cltv expiry - val sendPayment = pr.minFinalCltvExpiry match { - case None => SendPayment(amount_msat, pr.paymentHash, pr.nodeId) - case Some(minFinalCltvExpiry) => SendPayment(amount_msat, pr.paymentHash, pr.nodeId, assistedRoutes = Nil, minFinalCltvExpiry) - } - completeRpcFuture(req.id, (paymentInitiator ? sendPayment).mapTo[PaymentResult].map { - case s: PaymentSucceeded => s - case f: PaymentFailed => f.copy(failures = PaymentLifecycle.transformForUser(f.failures)) - }) - case _ => reject(RpcValidationRejection(req.id, s"payment request is not valid")) - } - case _ => reject(UnknownParamsRejection(req.id, "[amountMsat, paymentHash, nodeId or [paymentRequest] or [paymentRequest, amountMsat]")) - } - - // check received payments - case "checkpayment" => req.params match { - case JString(identifier) :: Nil => completeRpcFuture(req.id, for { - paymentHash <- Try(PaymentRequest.read(identifier)) match { - case Success(pr) => Future.successful(pr.paymentHash) - case _ => Try(ByteVector.fromValidHex(identifier)) match { - case Success(s) => Future.successful(s) - case _ => Future.failed(new IllegalArgumentException("payment identifier must be a payment request or a payment hash")) - } - } - found <- (paymentHandler ? CheckPayment(ByteVector32.fromValidHex(identifier))).map(found => new JBool(found.asInstanceOf[Boolean])) - } yield found) - case _ => reject(UnknownParamsRejection(req.id, "[paymentHash] or [paymentRequest]")) - } - - // retrieve audit events - case "audit" => - val (from, to) = req.params match { - case JInt(from) :: JInt(to) :: Nil => (from.toLong, to.toLong) - case _ => (0L, Long.MaxValue) - } - completeRpcFuture(req.id, Future(AuditResponse( - sent = nodeParams.db.audit.listSent(from, to), - received = nodeParams.db.audit.listReceived(from, to), - relayed = nodeParams.db.audit.listRelayed(from, to)) - )) - - case "networkfees" => - val (from, to) = req.params match { - case JInt(from) :: JInt(to) :: Nil => (from.toLong, to.toLong) - case _ => (0L, Long.MaxValue) - } - completeRpcFuture(req.id, Future(nodeParams.db.audit.listNetworkFees(from, to))) - - // retrieve fee stats - case "channelstats" => completeRpcFuture(req.id, Future(nodeParams.db.audit.stats)) - - - // method name was not found - case _ => reject(UnknownMethodRejection(req.id)) - } - } - } - } - } ~ path("ws") { - handleWebSocketMessages(socketHandler) - } - } - } - } - } - - def getInfoResponse: Future[GetInfoResponse] - - def makeSocketHandler(system: ActorSystem)(implicit materializer: ActorMaterializer): Flow[Message, TextMessage.Strict, NotUsed] = { + lazy val makeSocketHandler: Flow[Message, TextMessage.Strict, NotUsed] = { // create a flow transforming a queue of string -> string val (flowInput, flowOutput) = Source.queue[String](10, OverflowStrategy.dropTail).toMat(BroadcastHub.sink[String])(Keep.both).run() // register an actor that feeds the queue when a payment is received - system.actorOf(Props(new Actor { + actorSystem.actorOf(Props(new Actor { override def preStart: Unit = context.system.eventStream.subscribe(self, classOf[PaymentReceived]) - def receive: Receive = { case received: PaymentReceived => flowInput.offer(received.paymentHash.toString) } + + def receive: Receive = { + case received: PaymentReceived => flowInput.offer(received.paymentHash.toString) + } })) Flow[Message] @@ -370,53 +79,146 @@ trait Service extends Logging { .map(TextMessage.apply) } - def help = List( - "connect (uri): open a secure connection to a lightning node", - "connect (nodeId, host, port): open a secure connection to a lightning node", - "open (nodeId, fundingSatoshis, pushMsat = 0, feerateSatPerByte = ?, channelFlags = 0x01): open a channel with another lightning node, by default push = 0, feerate for the funding tx targets 6 blocks, and channel is announced", - "updaterelayfee (channelId, feeBaseMsat, feeProportionalMillionths): update relay fee for payments going through this channel", - "peers: list existing local peers", - "channels: list existing local channels", - "channels (nodeId): list existing local channels to a particular nodeId", - "channel (channelId): retrieve detailed information about a given channel", - "channelstats: retrieves statistics about channel usage (fees, number and average amount of payments)", - "allnodes: list all known nodes", - "allchannels: list all known channels", - "allupdates: list all channels updates", - "allupdates (nodeId): list all channels updates for this nodeId", - "receive (amountMsat, description): generate a payment request for a given amount", - "receive (amountMsat, description, expirySeconds): generate a payment request for a given amount with a description and a number of seconds till it expires", - "parseinvoice (paymentRequest): returns node, amount and payment hash in a payment request", - "findroute (paymentRequest): returns nodes and channels of the route if there is any", - "findroute (paymentRequest, amountMsat): returns nodes and channels of the route if there is any", - "findroute (nodeId, amountMsat): returns nodes and channels of the route if there is any", - "send (amountMsat, paymentHash, nodeId): send a payment to a lightning node", - "send (paymentRequest): send a payment to a lightning node using a BOLT11 payment request", - "send (paymentRequest, amountMsat): send a payment to a lightning node using a BOLT11 payment request and a custom amount", - "close (channelId): close a channel", - "close (channelId, scriptPubKey): close a channel and send the funds to the given scriptPubKey", - "forceclose (channelId): force-close a channel by publishing the local commitment tx (careful: this is more expensive than a regular close and will incur a delay before funds are spendable)", - "checkpayment (paymentHash): returns true if the payment has been received, false otherwise", - "checkpayment (paymentRequest): returns true if the payment has been received, false otherwise", - "audit: list all send/received/relayed payments", - "audit (from, to): list send/received/relayed payments in that interval (from <= timestamp < to)", - "networkfees: list all network fees paid to the miners, by transaction", - "networkfees (from, to): list network fees paid to the miners, by transaction, in that interval (from <= timestamp < to)", - "getinfo: returns info about the blockchain and this node", - "help: display this message") + val timeoutResponse: HttpRequest => HttpResponse = { r => + HttpResponse(StatusCodes.RequestTimeout).withEntity(ContentTypes.`application/json`, serialization.writePretty(ErrorResponse("request timed out"))) + } - /** - * Sends a request to a channel and expects a response - * - * @param channelIdentifier can be a shortChannelId (BOLT encoded) or a channelId (32-byte hex encoded) - * @param request - * @return - */ - def sendToChannel(channelIdentifier: String, request: Any): Future[Any] = - for { - fwdReq <- Future(Register.ForwardShortId(ShortChannelId(channelIdentifier), request)) - .recoverWith { case _ => Future(Register.Forward(ByteVector32.fromValidHex(channelIdentifier), request)) } - .recoverWith { case _ => Future.failed(new RuntimeException(s"invalid channel identifier '$channelIdentifier'")) } - res <- appKit.register ? fwdReq - } yield res -} + def userPassAuthenticator(credentials: Credentials): Future[Option[String]] = credentials match { + case p@Credentials.Provided(id) if p.verify(password) => Future.successful(Some(id)) + case _ => akka.pattern.after(1 second, using = actorSystem.scheduler)(Future.successful(None))(actorSystem.dispatcher) // force a 1 sec pause to deter brute force + } + + val route: Route = { + respondWithDefaultHeaders(customHeaders) { + handleExceptions(apiExceptionHandler) { + handleRejections(apiRejectionHandler){ + withRequestTimeoutResponse(timeoutResponse) { + authenticateBasicAsync(realm = "Access restricted", userPassAuthenticator) { _ => + post { + path("getinfo") { + complete(eclairApi.getInfoResponse()) + } ~ + path("connect") { + formFields("uri".as[String]) { uri => + complete(eclairApi.connect(uri)) + } ~ formFields("nodeId".as[PublicKey], "host".as[String], "port".as[Int].?) { (nodeId, host, port_opt) => + complete(eclairApi.connect(s"$nodeId@$host:${port_opt.getOrElse(NodeURI.DEFAULT_PORT)}")) + } + } ~ + path("open") { + formFields("nodeId".as[PublicKey], "fundingSatoshis".as[Long], "pushMsat".as[Long].?, "fundingFeerateSatByte".as[Long].?, "channelFlags".as[Int].?) { + (nodeId, fundingSatoshis, pushMsat, fundingFeerateSatByte, channelFlags) => + complete(eclairApi.open(nodeId, fundingSatoshis, pushMsat, fundingFeerateSatByte, channelFlags)) + } + } ~ + path("close") { + formFields(channelId, "scriptPubKey".as[ByteVector](binaryDataUnmarshaller).?) { (channelId, scriptPubKey_opt) => + complete(eclairApi.close(Left(channelId), scriptPubKey_opt)) + } ~ formFields(shortChannelId, "scriptPubKey".as[ByteVector](binaryDataUnmarshaller).?) { (shortChannelId, scriptPubKey_opt) => + complete(eclairApi.close(Right(shortChannelId), scriptPubKey_opt)) + } + } ~ + path("forceclose") { + formFields(channelId) { channelId => + complete(eclairApi.forceClose(Left(channelId))) + } ~ formFields(shortChannelId) { shortChannelId => + complete(eclairApi.forceClose(Right(shortChannelId))) + } + } ~ + path("updaterelayfee") { + formFields(channelId, "feeBaseMsat".as[Long], "feeProportionalMillionths".as[Long]) { (channelId, feeBase, feeProportional) => + complete(eclairApi.updateRelayFee(channelId.toString, feeBase, feeProportional)) + } + } ~ + path("peers") { + complete(eclairApi.peersInfo()) + } ~ + path("channels") { + formFields("toRemoteNodeId".as[PublicKey].?) { toRemoteNodeId_opt => + complete(eclairApi.channelsInfo(toRemoteNodeId_opt)) + } + } ~ + path("channel") { + formFields(channelId) { channelId => + complete(eclairApi.channelInfo(channelId)) + } + } ~ + path("allnodes") { + complete(eclairApi.allnodes()) + } ~ + path("allchannels") { + complete(eclairApi.allchannels()) + } ~ + path("allupdates") { + formFields("nodeId".as[PublicKey].?) { nodeId_opt => + complete(eclairApi.allupdates(nodeId_opt)) + } + } ~ + path("receive") { + formFields("description".as[String], "amountMsat".as[Long].?, "expireIn".as[Long].?) { (desc, amountMsat, expire) => + complete(eclairApi.receive(desc, amountMsat, expire)) + } + } ~ + path("parseinvoice") { + formFields("invoice".as[PaymentRequest]) { invoice => + complete(invoice) + } + } ~ + path("findroute") { + formFields("invoice".as[PaymentRequest], "amountMsat".as[Long].?) { + case (invoice@PaymentRequest(_, Some(amount), _, nodeId, _, _), None) => complete(eclairApi.findRoute(nodeId, amount.toLong, invoice.routingInfo)) + case (invoice, Some(overrideAmount)) => complete(eclairApi.findRoute(invoice.nodeId, overrideAmount, invoice.routingInfo)) + case _ => reject(MalformedFormFieldRejection("invoice", "The invoice must have an amount or you need to specify one using 'amountMsat'")) + } + } ~ path("findroutetonode") { + formFields("nodeId".as[PublicKey], "amountMsat".as[Long]) { (nodeId, amount) => + complete(eclairApi.findRoute(nodeId, amount)) + } + } ~ + path("send") { + formFields("invoice".as[PaymentRequest], "amountMsat".as[Long].?) { + case (invoice@PaymentRequest(_, Some(amount), _, nodeId, _, _), None) => + complete(eclairApi.send(nodeId, amount.toLong, invoice.paymentHash, invoice.routingInfo, invoice.minFinalCltvExpiry)) + case (invoice, Some(overrideAmount)) => + complete(eclairApi.send(invoice.nodeId, overrideAmount, invoice.paymentHash, invoice.routingInfo, invoice.minFinalCltvExpiry)) + case _ => reject(MalformedFormFieldRejection("invoice", "The invoice must have an amount or you need to specify one using the field 'amountMsat'")) + } + } ~ + path("sendtonode") { + formFields("amountMsat".as[Long], "paymentHash".as[ByteVector32](sha256HashUnmarshaller), "nodeId".as[PublicKey]) { (amountMsat, paymentHash, nodeId) => + complete(eclairApi.send(nodeId, amountMsat, paymentHash)) + } + } ~ + path("checkpayment") { + formFields("paymentHash".as[ByteVector32](sha256HashUnmarshaller)) { paymentHash => + complete(eclairApi.checkpayment(paymentHash)) + } ~ formFields("invoice".as[PaymentRequest]) { invoice => + complete(eclairApi.checkpayment(invoice.paymentHash)) + } + } ~ + path("audit") { + formFields("from".as[Long].?, "to".as[Long].?) { (from, to) => + complete(eclairApi.audit(from, to)) + } + } ~ + path("networkfees") { + formFields("from".as[Long].?, "to".as[Long].?) { (from, to) => + complete(eclairApi.networkFees(from, to)) + } + } ~ + path("channelstats") { + complete(eclairApi.channelStats()) + } ~ + path("ws") { + handleWebSocketMessages(makeSocketHandler) + } + } + } + } + } + } + } + } + + +} \ No newline at end of file diff --git a/eclair-core/src/test/resources/api/close b/eclair-core/src/test/resources/api/close index b7ab40e6c..06b1bfe62 100644 --- a/eclair-core/src/test/resources/api/close +++ b/eclair-core/src/test/resources/api/close @@ -1,4 +1 @@ -{ - "result" : "03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0", - "id" : "eclair-node" -} \ No newline at end of file +"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0" \ No newline at end of file diff --git a/eclair-core/src/test/resources/api/getinfo b/eclair-core/src/test/resources/api/getinfo index 53cb58243..1fbb200c3 100644 --- a/eclair-core/src/test/resources/api/getinfo +++ b/eclair-core/src/test/resources/api/getinfo @@ -1,11 +1 @@ -{ - "result" : { - "publicAddresses" : [ "localhost:9731" ], - "alias" : "alice", - "port" : 9735, - "chainHash" : "06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", - "blockHeight" : 123456, - "nodeId" : "03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0" - }, - "id" : "eclair-node" -} \ No newline at end of file +{"nodeId":"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0","alias":"alice","chainHash":"06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f","blockHeight":9999,"publicAddresses":["localhost:9731"]} \ No newline at end of file diff --git a/eclair-core/src/test/resources/api/help b/eclair-core/src/test/resources/api/help index d7a3a511c..9d58627ae 100644 --- a/eclair-core/src/test/resources/api/help +++ b/eclair-core/src/test/resources/api/help @@ -1,4 +1 @@ -{ - "result" : [ "connect (uri): open a secure connection to a lightning node", "connect (nodeId, host, port): open a secure connection to a lightning node", "open (nodeId, fundingSatoshis, pushMsat = 0, feerateSatPerByte = ?, channelFlags = 0x01): open a channel with another lightning node, by default push = 0, feerate for the funding tx targets 6 blocks, and channel is announced", "updaterelayfee (channelId, feeBaseMsat, feeProportionalMillionths): update relay fee for payments going through this channel", "peers: list existing local peers", "channels: list existing local channels", "channels (nodeId): list existing local channels to a particular nodeId", "channel (channelId): retrieve detailed information about a given channel", "channelstats: retrieves statistics about channel usage (fees, number and average amount of payments)", "allnodes: list all known nodes", "allchannels: list all known channels", "allupdates: list all channels updates", "allupdates (nodeId): list all channels updates for this nodeId", "receive (amountMsat, description): generate a payment request for a given amount", "receive (amountMsat, description, expirySeconds): generate a payment request for a given amount with a description and a number of seconds till it expires", "parseinvoice (paymentRequest): returns node, amount and payment hash in a payment request", "findroute (paymentRequest): returns nodes and channels of the route if there is any", "findroute (paymentRequest, amountMsat): returns nodes and channels of the route if there is any", "findroute (nodeId, amountMsat): returns nodes and channels of the route if there is any", "send (amountMsat, paymentHash, nodeId): send a payment to a lightning node", "send (paymentRequest): send a payment to a lightning node using a BOLT11 payment request", "send (paymentRequest, amountMsat): send a payment to a lightning node using a BOLT11 payment request and a custom amount", "close (channelId): close a channel", "close (channelId, scriptPubKey): close a channel and send the funds to the given scriptPubKey", "forceclose (channelId): force-close a channel by publishing the local commitment tx (careful: this is more expensive than a regular close and will incur a delay before funds are spendable)", "checkpayment (paymentHash): returns true if the payment has been received, false otherwise", "checkpayment (paymentRequest): returns true if the payment has been received, false otherwise", "audit: list all send/received/relayed payments", "audit (from, to): list send/received/relayed payments in that interval (from <= timestamp < to)", "networkfees: list all network fees paid to the miners, by transaction", "networkfees (from, to): list network fees paid to the miners, by transaction, in that interval (from <= timestamp < to)", "getinfo: returns info about the blockchain and this node", "help: display this message" ], - "id" : "eclair-node" -} \ No newline at end of file +["connect (uri): open a secure connection to a lightning node","connect (nodeId, host, port): open a secure connection to a lightning node","open (nodeId, fundingSatoshis, pushMsat = 0, feerateSatPerByte = ?, channelFlags = 0x01): open a channel with another lightning node, by default push = 0, feerate for the funding tx targets 6 blocks, and channel is announced","updaterelayfee (channelId, feeBaseMsat, feeProportionalMillionths): update relay fee for payments going through this channel","peers: list existing local peers","channels: list existing local channels","channels (nodeId): list existing local channels to a particular nodeId","channel (channelId): retrieve detailed information about a given channel","channelstats: retrieves statistics about channel usage (fees, number and average amount of payments)","allnodes: list all known nodes","allchannels: list all known channels","allupdates: list all channels updates","allupdates (nodeId): list all channels updates for this nodeId","receive (amountMsat, description): generate a payment request for a given amount","receive (amountMsat, description, expirySeconds): generate a payment request for a given amount with a description and a number of seconds till it expires","parseinvoice (paymentRequest): returns node, amount and payment hash in a payment request","findroute (paymentRequest): returns nodes and channels of the route if there is any","findroute (paymentRequest, amountMsat): returns nodes and channels of the route if there is any","findroute (nodeId, amountMsat): returns nodes and channels of the route if there is any","send (amountMsat, paymentHash, nodeId): send a payment to a lightning node","send (paymentRequest): send a payment to a lightning node using a BOLT11 payment request","send (paymentRequest, amountMsat): send a payment to a lightning node using a BOLT11 payment request and a custom amount","close (channelId): close a channel","close (channelId, scriptPubKey): close a channel and send the funds to the given scriptPubKey","forceclose (channelId): force-close a channel by publishing the local commitment tx (careful: this is more expensive than a regular close and will incur a delay before funds are spendable)","checkpayment (paymentHash): returns true if the payment has been received, false otherwise","checkpayment (paymentRequest): returns true if the payment has been received, false otherwise","audit: list all send/received/relayed payments","audit (from, to): list send/received/relayed payments in that interval (from <= timestamp < to)","networkfees: list all network fees paid to the miners, by transaction","networkfees (from, to): list network fees paid to the miners, by transaction, in that interval (from <= timestamp < to)","getinfo: returns info about the blockchain and this node","help: display this message"] \ No newline at end of file diff --git a/eclair-core/src/test/resources/api/peers b/eclair-core/src/test/resources/api/peers index 7b2def670..3e12eddaa 100644 --- a/eclair-core/src/test/resources/api/peers +++ b/eclair-core/src/test/resources/api/peers @@ -1,13 +1 @@ -{ - "result" : [ { - "nodeId" : "03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0", - "state" : "CONNECTED", - "address" : "localhost:9731", - "channels" : 1 - }, { - "nodeId" : "039dc0e0b1d25905e44fdf6f8e89755a5e219685840d0bc1d28d3308f9628a3585", - "state" : "DISCONNECTED", - "channels" : 1 - } ], - "id" : "eclair-node" -} \ No newline at end of file +[{"nodeId":"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0","state":"CONNECTED","address":"localhost:9731","channels":1},{"nodeId":"039dc0e0b1d25905e44fdf6f8e89755a5e219685840d0bc1d28d3308f9628a3585","state":"DISCONNECTED","channels":1}] \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala new file mode 100644 index 000000000..58c1b41c5 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala @@ -0,0 +1,265 @@ +/* + * Copyright 2018 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.api + +import akka.actor.{Actor, ActorSystem, Props, Scheduler} +import org.scalatest.FunSuite +import akka.http.scaladsl.model.StatusCodes._ +import akka.http.scaladsl.testkit.{RouteTestTimeout, ScalatestRouteTest} +import fr.acinq.eclair._ +import fr.acinq.eclair.io.Peer.{GetPeerInfo, PeerInfo} +import TestConstants._ +import akka.http.scaladsl.model.headers.BasicHttpCredentials +import akka.http.scaladsl.server.Route +import akka.stream.ActorMaterializer +import akka.http.scaladsl.model.{ContentTypes, FormData, MediaTypes, Multipart} +import fr.acinq.bitcoin.{ByteVector32, Crypto} +import fr.acinq.eclair.channel.RES_GETINFO +import fr.acinq.eclair.db.{NetworkFee, Stats} +import fr.acinq.eclair.payment.{PaymentLifecycle, PaymentRequest} +import fr.acinq.eclair.router.{ChannelDesc, RouteResponse} +import fr.acinq.eclair.wire.{ChannelUpdate, NodeAddress, NodeAnnouncement} +import scodec.bits.ByteVector +import scala.concurrent.Future +import scala.concurrent.duration._ +import scala.io.Source +import scala.reflect.ClassTag +import scala.util.Try + +class ApiServiceSpec extends FunSuite with ScalatestRouteTest { + + trait EclairMock extends Eclair { + override def connect(uri: String): Future[String] = ??? + + override def open(nodeId: Crypto.PublicKey, fundingSatoshis: Long, pushMsat: Option[Long], fundingFeerateSatByte: Option[Long], flags: Option[Int]): Future[String] = ??? + + override def close(channelIdentifier: Either[ByteVector32, ShortChannelId], scriptPubKey: Option[ByteVector]): Future[String] = ??? + + override def forceClose(channelIdentifier: Either[ByteVector32, ShortChannelId]): Future[String] = ??? + + override def updateRelayFee(channelId: String, feeBaseMsat: Long, feeProportionalMillionths: Long): Future[String] = ??? + + override def peersInfo(): Future[Iterable[PeerInfo]] = ??? + + override def channelsInfo(toRemoteNode: Option[Crypto.PublicKey]): Future[Iterable[RES_GETINFO]] = ??? + + override def channelInfo(channelId: ByteVector32): Future[RES_GETINFO] = ??? + + override def allnodes(): Future[Iterable[NodeAnnouncement]] = ??? + + override def allchannels(): Future[Iterable[ChannelDesc]] = ??? + + override def allupdates(nodeId: Option[Crypto.PublicKey]): Future[Iterable[ChannelUpdate]] = ??? + + override def receive(description: String, amountMsat: Option[Long], expire: Option[Long]): Future[String] = ??? + + override def findRoute(targetNodeId: Crypto.PublicKey, amountMsat: Long, assistedRoutes: Seq[Seq[PaymentRequest.ExtraHop]]): Future[RouteResponse] = ??? + + override def send(recipientNodeId: Crypto.PublicKey, amountMsat: Long, paymentHash: ByteVector32, assistedRoutes: Seq[Seq[PaymentRequest.ExtraHop]], minFinalCltvExpiry: Option[Long]): Future[PaymentLifecycle.PaymentResult] = ??? + + override def checkpayment(paymentHash: ByteVector32): Future[Boolean] = ??? + + override def audit(from_opt: Option[Long], to_opt: Option[Long]): Future[AuditResponse] = ??? + + override def networkFees(from_opt: Option[Long], to_opt: Option[Long]): Future[Seq[NetworkFee]] = ??? + + override def channelStats(): Future[Seq[Stats]] = ??? + + override def getInfoResponse(): Future[GetInfoResponse] = ??? + } + + implicit val formats = JsonSupport.formats + implicit val serialization = JsonSupport.serialization + implicit val marshaller = JsonSupport.marshaller + implicit val unmarshaller = JsonSupport.unmarshaller + + implicit val routeTestTimeout = RouteTestTimeout(3 seconds) + + class MockService(eclair: Eclair) extends Service { + override val eclairApi: Eclair = eclair + + override def password: String = "mock" + + override implicit val actorSystem: ActorSystem = system + override implicit val mat: ActorMaterializer = materializer + } + + test("API service should handle failures correctly") { + val mockService = new MockService(new EclairMock {}) + + // no auth + Post("/getinfo") ~> + Route.seal(mockService.route) ~> + check { + assert(handled) + assert(status == Unauthorized) + } + + // wrong auth + Post("/getinfo") ~> + addCredentials(BasicHttpCredentials("", mockService.password + "what!")) ~> + Route.seal(mockService.route) ~> + check { + assert(handled) + assert(status == Unauthorized) + } + + // correct auth but wrong URL + Post("/mistake") ~> + addCredentials(BasicHttpCredentials("", mockService.password)) ~> + Route.seal(mockService.route) ~> + check { + assert(handled) + assert(status == NotFound) + } + + // wrong param type + Post("/channel", FormData(Map("channelId" -> "hey")).toEntity) ~> + addCredentials(BasicHttpCredentials("", mockService.password)) ~> + Route.seal(mockService.route) ~> + check { + assert(handled) + assert(status == BadRequest) + val resp = entityAs[ErrorResponse](JsonSupport.unmarshaller, ClassTag(classOf[ErrorResponse])) + println(resp.error) + assert(resp.error == "The form field 'channelId' was malformed:\nInvalid hexadecimal character 'h' at index 0") + } + + // wrong params + Post("/connect", FormData("urb" -> "030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87@93.137.102.239:9735").toEntity) ~> + addCredentials(BasicHttpCredentials("", mockService.password)) ~> + Route.seal(mockService.route) ~> + check { + assert(handled) + assert(status == BadRequest) + } + + } + + test("'peers' should ask the switchboard for current known peers") { + + val mockService = new MockService(new EclairMock { + override def peersInfo(): Future[Iterable[PeerInfo]] = Future.successful(List( + PeerInfo( + nodeId = Alice.nodeParams.nodeId, + state = "CONNECTED", + address = Some(Alice.nodeParams.publicAddresses.head.socketAddress), + channels = 1), + PeerInfo( + nodeId = Bob.nodeParams.nodeId, + state = "DISCONNECTED", + address = None, + channels = 1))) + }) + + Post("/peers") ~> + addCredentials(BasicHttpCredentials("", mockService.password)) ~> + Route.seal(mockService.route) ~> + check { + assert(handled) + assert(status == OK) + val response = entityAs[String] + matchTestJson("peers", response) + } + } + + test("'getinfo' response should include this node ID") { + + val mockService = new MockService(new EclairMock { + override def getInfoResponse(): Future[GetInfoResponse] = Future.successful(GetInfoResponse( + nodeId = Alice.nodeParams.nodeId, + alias = Alice.nodeParams.alias, + chainHash = Alice.nodeParams.chainHash, + blockHeight = 9999, + publicAddresses = NodeAddress.fromParts("localhost", 9731).get :: Nil + )) + }) + + Post("/getinfo") ~> + addCredentials(BasicHttpCredentials("", mockService.password)) ~> + Route.seal(mockService.route) ~> + check { + assert(handled) + assert(status == OK) + val resp = entityAs[String] + assert(resp.toString.contains(Alice.nodeParams.nodeId.toString)) + matchTestJson("getinfo", resp) + } + } + + test("'close' method should accept a shortChannelId") { + + val shortChannelIdSerialized = "42000x27x3" + + val mockService = new MockService(new EclairMock { + override def close(channelIdentifier: Either[ByteVector32, ShortChannelId], scriptPubKey: Option[ByteVector]): Future[String] = { + Future.successful(Alice.nodeParams.nodeId.toString()) + } + }) + + Post("/close", FormData("shortChannelId" -> shortChannelIdSerialized).toEntity) ~> + addCredentials(BasicHttpCredentials("", mockService.password)) ~> + addHeader("Content-Type", "application/json") ~> + Route.seal(mockService.route) ~> + check { + assert(handled) + assert(status == OK) + val resp = entityAs[String] + assert(resp.contains(Alice.nodeParams.nodeId.toString)) + matchTestJson("close", resp) + } + } + + test("'connect' method should accept an URI and a triple with nodeId/host/port") { + + val remoteNodeId = "030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87" + val remoteHost = "93.137.102.239" + val remoteUri = "030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87@93.137.102.239:9735" + + val mockService = new MockService(new EclairMock { + override def connect(uri: String): Future[String] = Future.successful("connected") + }) + + Post("/connect", FormData("nodeId" -> remoteNodeId, "host" -> remoteHost).toEntity) ~> + addCredentials(BasicHttpCredentials("", mockService.password)) ~> + Route.seal(mockService.route) ~> + check { + assert(handled) + assert(status == OK) + assert(entityAs[String] == "\"connected\"") + } + + Post("/connect", FormData("uri" -> remoteUri).toEntity) ~> + addCredentials(BasicHttpCredentials("", mockService.password)) ~> + Route.seal(mockService.route) ~> + check { + assert(handled) + assert(status == OK) + println(entityAs[String]) + assert(entityAs[String] == "\"connected\"") + } + } + + private def matchTestJson(apiName: String, response: String) = { + val resource = getClass.getResourceAsStream(s"/api/$apiName") + val expectedResponse = Try(Source.fromInputStream(resource).mkString).getOrElse { + throw new IllegalArgumentException(s"Mock file for $apiName not found") + } + assert(response == expectedResponse, s"Test mock for $apiName did not match the expected response") + } + +} \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/api/JsonRpcServiceSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/api/JsonRpcServiceSpec.scala deleted file mode 100644 index 41cd68017..000000000 --- a/eclair-core/src/test/scala/fr/acinq/eclair/api/JsonRpcServiceSpec.scala +++ /dev/null @@ -1,279 +0,0 @@ -/* - * Copyright 2018 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.eclair.api - - -import java.io.{File, FileOutputStream} - -import akka.NotUsed -import akka.actor.{Actor, Props, Scheduler} -import akka.http.scaladsl.model.StatusCodes._ -import akka.http.scaladsl.model.headers.BasicHttpCredentials -import akka.http.scaladsl.model.ws.{Message, TextMessage} -import akka.http.scaladsl.server.Route -import akka.http.scaladsl.testkit.{RouteTestTimeout, ScalatestRouteTest} -import akka.stream.scaladsl.Flow -import de.heikoseeberger.akkahttpjson4s.Json4sSupport.{marshaller, unmarshaller} -import fr.acinq.eclair.Kit -import fr.acinq.eclair.TestConstants._ -import fr.acinq.eclair.blockchain.TestWallet -import fr.acinq.eclair.channel.Register.ForwardShortId -import fr.acinq.eclair.io.Peer.{GetPeerInfo, PeerInfo} -import org.json4s.Formats -import org.json4s.JsonAST.{JInt, JString} -import org.json4s.jackson.Serialization -import org.scalatest.FunSuite - -import scala.concurrent.Future -import scala.concurrent.duration._ -import scala.io.Source -import scala.util.Try - -class JsonRpcServiceSpec extends FunSuite with ScalatestRouteTest { - - implicit val routeTestTimeout = RouteTestTimeout(3 seconds) - - def defaultMockKit = Kit( - nodeParams = Alice.nodeParams, - system = system, - watcher = system.actorOf(Props(new MockActor)), - paymentHandler = system.actorOf(Props(new MockActor)), - register = system.actorOf(Props(new MockActor)), - relayer = system.actorOf(Props(new MockActor)), - router = system.actorOf(Props(new MockActor)), - switchboard = system.actorOf(Props(new MockActor)), - paymentInitiator = system.actorOf(Props(new MockActor)), - server = system.actorOf(Props(new MockActor)), - wallet = new TestWallet - ) - - class MockActor extends Actor { - override def receive: Receive = { case _ => } - } - - class MockService(kit: Kit = defaultMockKit) extends Service { - override def getInfoResponse: Future[GetInfoResponse] = Future.successful(???) - - override def appKit: Kit = kit - - override val scheduler: Scheduler = system.scheduler - - override def password: String = "mock" - - override val socketHandler: Flow[Message, TextMessage.Strict, NotUsed] = makeSocketHandler(system)(materializer) - } - - test("API service should handle failures correctly"){ - val mockService = new MockService - import mockService.{formats, serialization} - - // no auth - Post("/", JsonRPCBody(method = "help", params = Seq.empty)) ~> - Route.seal(mockService.route) ~> - check { - assert(handled) - assert(status == Unauthorized) - } - - // wrong auth - Post("/", JsonRPCBody(method = "help", params = Seq.empty)) ~> - addCredentials(BasicHttpCredentials("", mockService.password+"what!")) ~> - addHeader("Content-Type", "application/json") ~> - Route.seal(mockService.route) ~> - check { - assert(handled) - assert(status == Unauthorized) - } - - // correct auth but wrong URL - Post("/mistake", JsonRPCBody(method = "help", params = Seq.empty)) ~> - addCredentials(BasicHttpCredentials("", mockService.password)) ~> - addHeader("Content-Type", "application/json") ~> - Route.seal(mockService.route) ~> - check { - assert(handled) - assert(status == NotFound) - } - - // wrong rpc method - Post("/", JsonRPCBody(method = "open_not_really", params = Seq.empty)) ~> - addCredentials(BasicHttpCredentials("", mockService.password)) ~> - addHeader("Content-Type", "application/json") ~> - Route.seal(mockService.route) ~> - check { - assert(handled) - assert(status == BadRequest) - } - - // wrong params - Post("/", JsonRPCBody(method = "open", params = Seq(JInt(123), JString("abc")))) ~> - addCredentials(BasicHttpCredentials("", mockService.password)) ~> - addHeader("Content-Type", "application/json") ~> - Route.seal(mockService.route) ~> - check { - assert(handled) - assert(status == BadRequest) - } - - } - - test("'help' should respond with a help message") { - val mockService = new MockService - import mockService.{formats, serialization} - - val postBody = JsonRPCBody(method = "help", params = Seq.empty) - - Post("/", postBody) ~> - addCredentials(BasicHttpCredentials("", mockService.password)) ~> - addHeader("Content-Type", "application/json") ~> - Route.seal(mockService.route) ~> - check { - assert(handled) - assert(status == OK) - val resp = entityAs[JsonRPCRes] - matchTestJson("help", false ,resp) - } - - - } - - test("'peers' should ask the switchboard for current known peers") { - - val mockAlicePeer = system.actorOf(Props(new {} with MockActor { - override def receive = { - case GetPeerInfo => sender() ! PeerInfo( - nodeId = Alice.nodeParams.nodeId, - state = "CONNECTED", - address = Some(Alice.nodeParams.publicAddresses.head.socketAddress), - channels = 1) - } - })) - - val mockBobPeer = system.actorOf(Props(new {} with MockActor { - override def receive = { - case GetPeerInfo => sender() ! PeerInfo( - nodeId = Bob.nodeParams.nodeId, - state = "DISCONNECTED", - address = None, - channels = 1) - } - })) - - - val mockService = new MockService(defaultMockKit.copy( - switchboard = system.actorOf(Props(new {} with MockActor { - override def receive = { - case 'peers => sender() ! List(mockAlicePeer, mockBobPeer) - } - })) - )) - - import mockService.{formats, serialization} - - val postBody = JsonRPCBody(method = "peers", params = Seq.empty) - - Post("/", postBody) ~> - addCredentials(BasicHttpCredentials("", mockService.password)) ~> - addHeader("Content-Type", "application/json") ~> - Route.seal(mockService.route) ~> - check { - assert(handled) - assert(status == OK) - val response = entityAs[JsonRPCRes] - val peerInfos = response.result.asInstanceOf[Seq[Map[String,String]]] - assert(peerInfos.size == 2) - assert(peerInfos.head.get("nodeId") == Some(Alice.nodeParams.nodeId.toString)) - assert(peerInfos.head.get("state") == Some("CONNECTED")) - matchTestJson("peers", false, response) - } - } - - test("'getinfo' response should include this node ID") { - val mockService = new {} with MockService { - override def getInfoResponse: Future[GetInfoResponse] = Future.successful(GetInfoResponse( - nodeId = Alice.nodeParams.nodeId, - alias = Alice.nodeParams.alias, - port = 9735, - chainHash = Alice.nodeParams.chainHash, - blockHeight = 123456, - publicAddresses = Alice.nodeParams.publicAddresses - )) - } - import mockService.{formats, serialization} - - val postBody = JsonRPCBody(method = "getinfo", params = Seq.empty) - - Post("/", postBody) ~> - addCredentials(BasicHttpCredentials("", mockService.password)) ~> - addHeader("Content-Type", "application/json") ~> - Route.seal(mockService.route) ~> - check { - assert(handled) - assert(status == OK) - val resp = entityAs[JsonRPCRes] - assert(resp.result.toString.contains(Alice.nodeParams.nodeId.toString)) - matchTestJson("getinfo", false ,resp) - } - } - - test("'close' method should accept a shortChannelId") { - - val shortChannelIdSerialized = "42000x27x3" - - val mockService = new MockService(defaultMockKit.copy( - register = system.actorOf(Props(new {} with MockActor { - override def receive = { - case ForwardShortId(shortChannelId, _) if shortChannelId.toString == shortChannelIdSerialized => - sender() ! Alice.nodeParams.nodeId.toString - } - })) - )) - - import mockService.{formats, serialization} - - - val postBody = JsonRPCBody(method = "close", params = Seq(JString(shortChannelIdSerialized))) - - Post("/", postBody) ~> - addCredentials(BasicHttpCredentials("", mockService.password)) ~> - addHeader("Content-Type", "application/json") ~> - Route.seal(mockService.route) ~> - check { - assert(handled) - assert(status == OK) - val resp = entityAs[JsonRPCRes] - assert(resp.result.toString.contains(Alice.nodeParams.nodeId.toString)) - matchTestJson("close", false ,resp) - } - } - - private def readFileAsString(stream: File): Try[String] = Try(Source.fromFile(stream).mkString) - - private def matchTestJson(rpcMethod: String, overWrite: Boolean, response: JsonRPCRes)(implicit formats: Formats) = { - val responseContent = Serialization.writePretty(response) - val resourceName = s"/api/$rpcMethod" - val resourceFile = new File(getClass.getResource(resourceName).toURI.toURL.getFile) - if(overWrite) { - new FileOutputStream(resourceFile).write(responseContent.getBytes) - assert(false, "'overWrite' should be false before commit") - } else { - val expectedResponse = readFileAsString(resourceFile).getOrElse(throw new IllegalArgumentException(s"Mock file for '$resourceName' does not exist, please use 'overWrite' first.")) - assert(responseContent == expectedResponse, s"Test mock for $rpcMethod did not match the expected response") - } - } - -} \ No newline at end of file