mirror of
https://github.com/ACINQ/eclair.git
synced 2025-02-21 14:04:10 +01:00
API: use form data instead of JSON-RPC (#894)
Port the existing API functionalities over a new structure of HTTP endpoints, with the biggest difference being the usage of **named parameters** for the requests (responses are unchanged). RPC methods have become endpoints and the parameters for each are now passed via form-params (clients must use the header "Content-Type" : "multipart/form-data"), this allows for a clearer interpretation of the parameters and results in more elegant parsing code on the server side. It is possible to still use the old API version via a configuration key. Old API can be used by setting `eclair.api.use-old-api=true`.
This commit is contained in:
parent
89ddc52640
commit
a4b94004e4
18 changed files with 1335 additions and 843 deletions
13
BUILD.md
13
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
|
40
OLD-API-DOCS.md
Normal file
40
OLD-API-DOCS.md
Normal file
|
@ -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
|
48
README.md
48
README.md
|
@ -4,7 +4,7 @@
|
|||
[](LICENSE)
|
||||
[](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-<version>-<commit_id>.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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 <password> API's password
|
||||
-a <address> Override the API URL with <address>
|
||||
-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: <https://acinq.github.io/apidoc>" 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 <password> api's password
|
||||
-a <address> Override the api URL with <address>
|
||||
-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: <https://github.com/ACINQ/eclair>"
|
||||
}
|
||||
|
||||
# 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"
|
||||
|
|
|
@ -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"
|
||||
|
|
197
eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Normal file
197
eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Normal file
|
@ -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)
|
||||
)
|
||||
|
||||
}
|
|
@ -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"))))
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
422
eclair-core/src/main/scala/fr/acinq/eclair/api/OldService.scala
Normal file
422
eclair-core/src/main/scala/fr/acinq/eclair/api/OldService.scala
Normal file
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -1,4 +1 @@
|
|||
{
|
||||
"result" : "03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0",
|
||||
"id" : "eclair-node"
|
||||
}
|
||||
"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0"
|
|
@ -1,11 +1 @@
|
|||
{
|
||||
"result" : {
|
||||
"publicAddresses" : [ "localhost:9731" ],
|
||||
"alias" : "alice",
|
||||
"port" : 9735,
|
||||
"chainHash" : "06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f",
|
||||
"blockHeight" : 123456,
|
||||
"nodeId" : "03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0"
|
||||
},
|
||||
"id" : "eclair-node"
|
||||
}
|
||||
{"nodeId":"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0","alias":"alice","chainHash":"06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f","blockHeight":9999,"publicAddresses":["localhost:9731"]}
|
|
@ -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"
|
||||
}
|
||||
["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"]
|
|
@ -1,13 +1 @@
|
|||
{
|
||||
"result" : [ {
|
||||
"nodeId" : "03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0",
|
||||
"state" : "CONNECTED",
|
||||
"address" : "localhost:9731",
|
||||
"channels" : 1
|
||||
}, {
|
||||
"nodeId" : "039dc0e0b1d25905e44fdf6f8e89755a5e219685840d0bc1d28d3308f9628a3585",
|
||||
"state" : "DISCONNECTED",
|
||||
"channels" : 1
|
||||
} ],
|
||||
"id" : "eclair-node"
|
||||
}
|
||||
[{"nodeId":"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0","state":"CONNECTED","address":"localhost:9731","channels":1},{"nodeId":"039dc0e0b1d25905e44fdf6f8e89755a5e219685840d0bc1d28d3308f9628a3585","state":"DISCONNECTED","channels":1}]
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Reference in a new issue