1
0
Fork 0
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:
araspitzu 2019-03-26 18:10:09 +01:00 committed by Pierre-Marie Padiou
parent 89ddc52640
commit a4b94004e4
18 changed files with 1335 additions and 843 deletions

View file

@ -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
View 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

View file

@ -4,7 +4,7 @@
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
[![Gitter chat](https://img.shields.io/badge/chat-on%20gitter-red.svg)](https://gitter.im/ACINQ/eclair)
**Eclair** (French for Lightning) is a Scala implementation of the Lightning Network. It can run with or without a GUI, and a JSON-RPC API is also available.
**Eclair** (French for Lightning) is a Scala implementation of the Lightning Network. It can run with or without a GUI, and a JSON API is also available.
This software follows the [Lightning Network Specifications (BOLTs)](https://github.com/lightningnetwork/lightning-rfc). Other implementations include [c-lightning](https://github.com/ElementsProject/lightning) and [lnd](https://github.com/LightningNetwork/lnd).
@ -14,7 +14,7 @@ This software follows the [Lightning Network Specifications (BOLTs)](https://git
:rotating_light: If you intend to run Eclair on mainnet:
- Keep in mind that it is beta-quality software and **don't put too much money** in it
- Eclair's JSON-RPC API should **NOT** be accessible from the outside world (similarly to Bitcoin Core API)
- Eclair's JSON API should **NOT** be accessible from the outside world (similarly to Bitcoin Core API)
- Specific [configuration instructions for mainnet](#mainnet-usage) are provided below (by default Eclair runs on testnet)
---
@ -128,44 +128,14 @@ Eclair uses [`logback`](https://logback.qos.ch) for logging. To use a different
java -Dlogback.configurationFile=/path/to/logback-custom.xml -jar eclair-node-gui-<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

View file

@ -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

View file

@ -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"

View file

@ -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"

View 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)
)
}

View file

@ -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"))))

View file

@ -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)
}
}

View file

@ -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
}

View 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
}

View file

@ -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)
}
}
}
}
}
}
}
}
}

View file

@ -1,4 +1 @@
{
"result" : "03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0",
"id" : "eclair-node"
}
"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0"

View file

@ -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"]}

View file

@ -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"]

View file

@ -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}]

View file

@ -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")
}
}

View file

@ -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")
}
}
}