1
0
Fork 0
mirror of https://github.com/ACINQ/eclair.git synced 2025-02-20 13:34:35 +01:00

Merge branch 'master' into peer-ratelimit

This commit is contained in:
pm47 2019-01-21 19:15:08 +01:00
commit ad24fdb94b
No known key found for this signature in database
GPG key ID: E434ED292E85643A
31 changed files with 849 additions and 296 deletions

View file

@ -103,7 +103,7 @@ name | description
eclair.bitcoind.rpcpassword | Bitcoin Core RPC password | bar
eclair.bitcoind.zmqblock | Bitcoin Core ZMQ block address | "tcp://127.0.0.1:29000"
eclair.bitcoind.zmqtx | Bitcoin Core ZMQ tx address | "tcp://127.0.0.1:29000"
eclair.gui.unit | Unit in which amounts are displayed (possible values: msat, sat, mbtc, btc) | btc
eclair.gui.unit | Unit in which amounts are displayed (possible values: msat, sat, bits, mbtc, btc) | btc
Quotes are not required unless the value contains special characters. Full syntax guide [here](https://github.com/lightbend/config/blob/master/HOCON.md).
@ -155,8 +155,10 @@ java -Dlogback.configurationFile=/path/to/logback-custom.xml -jar eclair-node-gu
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
checkinvoice | paymentRequest | returns node, amount and payment hash in an invoice/paymentRequest
findroute | paymentRequest|paymentRequest, amountMsat|nodeId, amountMsat | given a payment request or nodeID and an amount checks if there is a valid payment route returns JSON with attempts, nodes and channels of route
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

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 checkpayment getinfo help"
allopts="connect open peers channels channel allnodes allchannels allupdates receive send close audit findroute updaterelayfee parseinvoice forceclose networkfees channelstats checkpayment getinfo help"
if ! [[ " $allopts " =~ " $prev " ]]; then # prevent double arguments
if [[ -z "$cur" || "$cur" =~ ^[a-z] ]]; then

View file

@ -94,4 +94,6 @@ eclair {
max-pending-payment-requests = 10000000
max-payment-fee = 0.03 // max total fee for outgoing payments, in percentage: sending a payment will not be attempted if the cheapest route found is more expensive than that
min-funding-satoshis = 100000
autoprobe-count = 0 // number of parallel tasks that send test payments to detect invalid channels
}

View file

@ -16,7 +16,7 @@
package fr.acinq.eclair
import java.text.DecimalFormat
import java.text.{DecimalFormat, NumberFormat}
import fr.acinq.bitcoin.{Btc, BtcAmount, MilliBtc, MilliSatoshi, Satoshi}
import grizzled.slf4j.Logging
@ -93,19 +93,44 @@ case object BtcUnit extends CoinUnit {
object CoinUtils extends Logging {
val COIN_PATTERN = "###,###,###,##0.###########"
var COIN_FORMAT = new DecimalFormat(COIN_PATTERN)
// msat pattern, no decimals allowed
val MILLI_SAT_PATTERN = "#,###,###,###,###,###,##0"
def setCoinPattern(pattern: String) = {
// sat pattern decimals are optional
val SAT_PATTERN = "#,###,###,###,###,##0.###"
// bits pattern always shows 2 decimals (msat optional)
val BITS_PATTERN = "##,###,###,###,##0.00###"
// milli btc pattern always shows 5 decimals (msat optional)
val MILLI_BTC_PATTERN = "##,###,###,##0.00000###"
// btc pattern always shows 8 decimals (msat optional). This is the default pattern.
val BTC_PATTERN = "##,###,##0.00000000###"
var COIN_FORMAT: NumberFormat = new DecimalFormat(BTC_PATTERN)
def setCoinPattern(pattern: String): Unit = {
COIN_FORMAT = new DecimalFormat(pattern)
}
def getPatternFromUnit(unit: CoinUnit): String = {
unit match {
case MSatUnit => MILLI_SAT_PATTERN
case SatUnit => SAT_PATTERN
case BitUnit => BITS_PATTERN
case MBtcUnit => MILLI_BTC_PATTERN
case BtcUnit => BTC_PATTERN
case _ => throw new IllegalArgumentException("unhandled unit")
}
}
/**
* Converts a string amount denominated in a bitcoin unit to a Millisatoshi amount. The amount might be truncated if
* it has too many decimals because MilliSatoshi only accepts Long amount.
*
* @param amount numeric String, can be decimal.
* @param unit bitcoin unit, can be milliSatoshi, Satoshi, milliBTC, BTC.
* @param unit bitcoin unit, can be milliSatoshi, Satoshi, Bits, milliBTC, BTC.
* @return amount as a MilliSatoshi object.
* @throws NumberFormatException if the amount parameter is not numeric.
* @throws IllegalArgumentException if the unit is not equals to milliSatoshi, Satoshi or milliBTC.
@ -132,7 +157,7 @@ object CoinUtils extends Logging {
fr.acinq.bitcoin.millisatoshi2satoshi(CoinUtils.convertStringAmountToMsat(amount, unit))
/**
* Only BtcUnit, MBtcUnit, SatUnit and MSatUnit codes or label are supported.
* Only BtcUnit, MBtcUnit, BitUnit, SatUnit and MSatUnit codes or label are supported.
* @param unit
* @return
*/
@ -199,13 +224,13 @@ object CoinUtils extends Logging {
}
/**
* Converts the amount to the user preferred unit and returns the Long value.
* Converts the amount to the user preferred unit and returns the BigDecimal value.
* This method is useful to feed numeric text input without formatting.
*
* Returns -1 if the given amount can not be converted.
*
* @param amount BtcAmount
* @return Long value of the BtcAmount
* @return BigDecimal value of the BtcAmount
*/
def rawAmountInUnit(amount: BtcAmount, unit: CoinUnit): BigDecimal = Try(convertAmountToGUIUnit(amount, unit) match {
case a: BtcAmountGUILossless => BigDecimal(a.amount_msat) / a.unit.factorToMsat

View file

@ -83,6 +83,7 @@ case class NodeParams(metrics: MetricRegistry = new MetricRegistry(),
maxPendingPaymentRequests: Int,
maxPaymentFee: Double,
minFundingSatoshis: Long) {
val privateKey = keyManager.nodeKey.privateKey
val nodeId = keyManager.nodeId
}
@ -172,6 +173,9 @@ object NodeParams {
val offeredCLTV = config.getInt("to-remote-delay-blocks")
require(maxToLocalCLTV <= Channel.MAX_TO_SELF_DELAY && offeredCLTV <= Channel.MAX_TO_SELF_DELAY, s"CLTV delay values too high, max is ${Channel.MAX_TO_SELF_DELAY}")
val nodeAlias = config.getString("node-alias")
require(nodeAlias.getBytes("UTF-8").length <= 32, "invalid alias, too long (max allowed 32 bytes)")
val overrideFeatures: Map[PublicKey, (BinaryData, BinaryData)] = config.getConfigList("override-features").map { e =>
val p = PublicKey(e.getString("nodeid"))
val gf = BinaryData(e.getString("global-features"))
@ -181,7 +185,7 @@ object NodeParams {
NodeParams(
keyManager = keyManager,
alias = config.getString("node-alias").take(32),
alias = nodeAlias,
color = Color(color.data(0), color.data(1), color.data(2)),
publicAddresses = config.getStringList("server.public-ips").toList.map(ip => new InetSocketAddress(InetAddresses.forString(ip), config.getInt("server.port"))),
globalFeatures = BinaryData(config.getString("global-features")),

View file

@ -259,6 +259,7 @@ class Setup(datadir: File,
switchboard = system.actorOf(SimpleSupervisor.props(Switchboard.props(nodeParams, authenticator, watcher, router, relayer, wallet), "switchboard", SupervisorStrategy.Resume))
server = system.actorOf(SimpleSupervisor.props(Server.props(nodeParams, authenticator, new InetSocketAddress(config.getString("server.binding-ip"), config.getInt("server.port")), Some(tcpBound)), "server", SupervisorStrategy.Restart))
paymentInitiator = system.actorOf(SimpleSupervisor.props(PaymentInitiator.props(nodeParams.nodeId, router, register), "payment-initiator", SupervisorStrategy.Restart))
_ = for (i <- 0 until config.getInt("autoprobe-count")) yield system.actorOf(SimpleSupervisor.props(Autoprobe.props(nodeParams, router, paymentInitiator), s"payment-autoprobe-$i", SupervisorStrategy.Restart))
kit = Kit(
nodeParams = nodeParams,

View file

@ -23,6 +23,7 @@ import fr.acinq.bitcoin.Crypto.{Point, PrivateKey, PublicKey, Scalar}
import fr.acinq.bitcoin.{BinaryData, MilliSatoshi, OutPoint, Transaction}
import fr.acinq.eclair.channel.State
import fr.acinq.eclair.crypto.ShaChain
import fr.acinq.eclair.payment.PaymentRequest
import fr.acinq.eclair.router.RouteResponse
import fr.acinq.eclair.transactions.Direction
import fr.acinq.eclair.transactions.Transactions.{InputInfo, TransactionWithInputInfo}
@ -132,3 +133,17 @@ class DirectionSerializer extends CustomSerializer[Direction](format => ({ null
case d: Direction => JString(d.toString)
}))
class PaymentRequestSerializer extends CustomSerializer[PaymentRequest](format => ({ null },{
case p: PaymentRequest => JObject(JField("prefix", JString(p.prefix)) ::
JField("amount", if (p.amount.isDefined) JLong(p.amount.get.toLong) else JNull) ::
JField("timestamp", JLong(p.timestamp)) ::
JField("nodeId", JString(p.nodeId.toString())) ::
JField("description", JString(p.description match {
case Left(l) => l.toString()
case Right(r) => r.toString()
})) ::
JField("paymentHash", JString(p.paymentHash.toString())) ::
JField("expiry", if (p.expiry.isDefined) JLong(p.expiry.get) else JNull) ::
JField("minFinalCltvExpiry", if (p.minFinalCltvExpiry.isDefined) JLong(p.minFinalCltvExpiry.get) else JNull) ::
Nil)
}))

View file

@ -74,7 +74,7 @@ trait Service extends Logging {
def scheduler: Scheduler
implicit val serialization = jackson.Serialization
implicit val formats = org.json4s.DefaultFormats + new BinaryDataSerializer + 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
implicit val formats = org.json4s.DefaultFormats + new BinaryDataSerializer + 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
@ -234,7 +234,8 @@ trait Service extends Logging {
case _ => reject(UnknownParamsRejection(req.id, "[description] or [amount, description] or [amount, description, expiryDuration]"))
}
case "checkinvoice" => req.params match {
// 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}"))
@ -384,8 +385,10 @@ trait Service extends Logging {
"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",
"checkinvoice (paymentRequest): returns node, amount and payment hash in an invoice/paymentRequest",
"findroute (paymentRequest|nodeId): given a payment request or nodeID checks if there is a valid payment route returns JSON with attempts, nodes and channels of route",
"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",

View file

@ -86,7 +86,8 @@ class ElectrumClient(serverAddress: InetSocketAddress, ssl: SSL)(implicit val ec
})
// Start the client.
log.info(s"connecting to $serverAddress")
log.info("connecting to server={}", serverAddress)
val channelOpenFuture = b.connect(serverAddress.getHostName, serverAddress.getPort)
def close() = {
@ -95,7 +96,7 @@ class ElectrumClient(serverAddress: InetSocketAddress, ssl: SSL)(implicit val ec
}
def errorHandler(t: Throwable) = {
log.info(s"connection error (reason=${t.getMessage})")
log.info("server={} connection error (reason={})", serverAddress, t.getMessage)
close()
}
@ -109,7 +110,7 @@ class ElectrumClient(serverAddress: InetSocketAddress, ssl: SSL)(implicit val ec
if (!future.isSuccess) {
errorHandler(future.cause())
} else {
log.info(s"channel closed: " + future.channel())
log.info("server={} channel closed: {}", serverAddress, future.channel())
close()
}
}
@ -168,7 +169,7 @@ class ElectrumClient(serverAddress: InetSocketAddress, ssl: SSL)(implicit val ec
import org.json4s._
import org.json4s.jackson.JsonMethods._
log.info(s"sending $request")
log.debug("sending {} to {}", request, serverAddress)
val json = ("method" -> request.method) ~ ("params" -> request.params.map {
case s: String => new JString(s)
case b: BinaryData => new JString(b.toString())
@ -202,7 +203,7 @@ class ElectrumClient(serverAddress: InetSocketAddress, ssl: SSL)(implicit val ec
var addressSubscriptions = Map.empty[String, Set[ActorRef]]
var scriptHashSubscriptions = Map.empty[BinaryData, Set[ActorRef]]
val headerSubscriptions = collection.mutable.HashSet.empty[ActorRef]
val version = ServerVersion("3.3.2", "1.4")
val version = ServerVersion(CLIENT_NAME, PROTOCOL_VERSION)
val statusListeners = collection.mutable.HashSet.empty[ActorRef]
val keepHeaders = 100
@ -223,7 +224,7 @@ class ElectrumClient(serverAddress: InetSocketAddress, ssl: SSL)(implicit val ec
case PingResponse => ()
case _ => log.warning(s"unhandled $message")
case _ => log.warning("server={} unhandled message {}", serverAddress, message)
}
}
@ -254,7 +255,7 @@ class ElectrumClient(serverAddress: InetSocketAddress, ssl: SSL)(implicit val ec
def disconnected: Receive = {
case ctx: ChannelHandlerContext =>
log.info(s"connected to $serverAddress")
log.info("connected to server={}", serverAddress)
send(ctx, version)
context become waitingForVersion(ctx)
@ -263,12 +264,17 @@ class ElectrumClient(serverAddress: InetSocketAddress, ssl: SSL)(implicit val ec
def waitingForVersion(ctx: ChannelHandlerContext): Receive = {
case Right(json: JsonRPCResponse) =>
val serverVersion = parseJsonResponse(version, json)
log.debug(s"serverVersion=$serverVersion")
send(ctx, HeaderSubscription(self))
headerSubscriptions += self
log.debug("waiting for tip")
context become waitingForTip(ctx)
(parseJsonResponse(version, json): @unchecked) match {
case ServerVersionResponse(clientName, protocolVersion) =>
log.info("server={} clientName={} protocolVersion={}", serverAddress, clientName, protocolVersion)
send(ctx, HeaderSubscription(self))
headerSubscriptions += self
log.debug("waiting for tip from server={}", serverAddress)
context become waitingForTip(ctx)
case ServerError(request, error) =>
log.error("server={} sent error={} while processing request={}, disconnecting", serverAddress, error, request)
close()
}
case AddStatusListener(actor) => statusListeners += actor
}
@ -276,7 +282,7 @@ class ElectrumClient(serverAddress: InetSocketAddress, ssl: SSL)(implicit val ec
def waitingForTip(ctx: ChannelHandlerContext): Receive = {
case Right(json: JsonRPCResponse) =>
val (height, header) = parseBlockHeader(json.result)
log.debug(s"connected, tip = ${header.hash} height = $height")
log.debug("connected to server={}, tip={} height={}", serverAddress, header.hash, height)
statusListeners.map(_ ! ElectrumReady(height, header, serverAddress))
context become connected(ctx, height, header, "", Map())
@ -310,10 +316,10 @@ class ElectrumClient(serverAddress: InetSocketAddress, ssl: SSL)(implicit val ec
requests.get(json.id) match {
case Some((request, requestor)) =>
val response = parseJsonResponse(request, json)
log.debug(s"got response for reqId=${json.id} request=$request response=$response")
log.debug("server={} sent response for reqId={} request={} response={}", serverAddress, json.id, request, response)
requestor ! response
case None =>
log.warning(s"could not find requestor for reqId=${json.id} response=$json")
log.warning("server={} could not find requestor for reqId=${} response={}", serverAddress, json.id, json)
}
context become connected(ctx, height, tip, buffer, requests - json.id)
@ -324,12 +330,14 @@ class ElectrumClient(serverAddress: InetSocketAddress, ssl: SSL)(implicit val ec
case Left(response: ScriptHashSubscriptionResponse) => scriptHashSubscriptions.get(response.scriptHash).map(listeners => listeners.map(_ ! response))
case HeaderSubscriptionResponse(height, newtip) =>
log.info(s"new tip $newtip")
log.info("server={} new tip={}", serverAddress, newtip)
context become connected(ctx, height, newtip, buffer, requests)
}
}
object ElectrumClient {
val CLIENT_NAME = "3.3.2" // client name that we will include in our "version" message
val PROTOCOL_VERSION = "1.4" // version of the protocol that we require
// this is expensive and shared with all clients
val workerGroup = new NioEventLoopGroup()

View file

@ -19,7 +19,7 @@ package fr.acinq.eclair.blockchain.electrum
import java.io.InputStream
import java.net.InetSocketAddress
import akka.actor.{Actor, ActorRef, FSM, Props, Terminated}
import akka.actor.{Actor, ActorRef, FSM, OneForOneStrategy, Props, SupervisorStrategy, Terminated}
import fr.acinq.bitcoin.BlockHeader
import fr.acinq.eclair.Globals
import fr.acinq.eclair.blockchain.CurrentBlockCount
@ -38,12 +38,19 @@ class ElectrumClientPool(serverAddresses: Set[ElectrumServerAddress])(implicit v
val statusListeners = collection.mutable.HashSet.empty[ActorRef]
val addresses = collection.mutable.Map.empty[ActorRef, InetSocketAddress]
// on startup, we attempt to connect to a number of electrum clients
// they will send us an `ElectrumReady` message when they're connected, or
// terminate if they cannot connect
(0 until Math.min(MAX_CONNECTION_COUNT, serverAddresses.size)) foreach (_ => self ! Connect)
log.debug(s"starting electrum pool with serverAddresses={}", serverAddresses)
log.debug("starting electrum pool with serverAddresses={}", serverAddresses)
// custom supervision strategy: always stop Electrum clients when there's a problem, we will automatically reconnect
// to another client
override def supervisorStrategy: SupervisorStrategy = OneForOneStrategy(loggingEnabled = true) {
case _ => SupervisorStrategy.stop
}
startWith(Disconnected, DisconnectedData)
@ -130,14 +137,17 @@ class ElectrumClientPool(serverAddresses: Set[ElectrumServerAddress])(implicit v
data match {
case None =>
// as soon as we have a connection to an electrum server, we select it as master
log.info(s"selecting master $remoteAddress} at $tip")
log.info("selecting master {} at {}", remoteAddress, tip)
statusListeners.foreach(_ ! ElectrumClient.ElectrumReady(height, tip, remoteAddress))
context.system.eventStream.publish(ElectrumClient.ElectrumReady(height, tip, remoteAddress))
goto(Connected) using ConnectedData(connection, Map(connection -> (height, tip)))
case Some(d) if height >= d.blockHeight + 2L =>
case Some(d) if connection != d.master && height >= d.blockHeight + 2L =>
// we only switch to a new master if there is a significant difference with our current master, because
// we don't want to switch to a new master every time a new block arrives (some servers will be notified before others)
log.info(s"switching to master $remoteAddress at $tip")
// we check that the current connection is not our master because on regtest when you generate several blocks at once
// (and maybe on testnet in some pathological cases where there's a block every second) it may seen like our master
// skipped a block and is suddenly at height + 2
log.info("switching to master {} at {}", remoteAddress, tip)
// we've switched to a new master, treat this as a disconnection/reconnection
// so users (wallet, watcher, ...) will reset their subscriptions
statusListeners.foreach(_ ! ElectrumClient.ElectrumDisconnected)
@ -146,7 +156,7 @@ class ElectrumClientPool(serverAddresses: Set[ElectrumServerAddress])(implicit v
context.system.eventStream.publish(ElectrumClient.ElectrumReady(height, tip, remoteAddress))
goto(Connected) using d.copy(master = connection, tips = d.tips + (connection -> (height, tip)))
case Some(d) =>
log.debug(s"received tip from $remoteAddress} $tip at $height")
log.debug("received tip {} from {} at {}", tip, remoteAddress, height)
stay using d.copy(tips = d.tips + (connection -> (height, tip)))
}
}
@ -154,7 +164,7 @@ class ElectrumClientPool(serverAddresses: Set[ElectrumServerAddress])(implicit v
private def updateBlockCount(blockCount: Long): Unit = {
// when synchronizing we don't want to advertise previous blocks
if (Globals.blockCount.get() < blockCount) {
log.debug(s"current blockchain height=$blockCount")
log.debug("current blockchain height={}", blockCount)
context.system.eventStream.publish(CurrentBlockCount(blockCount))
Globals.blockCount.set(blockCount)
}

View file

@ -763,8 +763,8 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu
}
}
case Event(c@CurrentBlockCount(count), d: DATA_NORMAL) if d.commitments.hasTimedoutOutgoingHtlcs(count) =>
handleLocalError(HtlcTimedout(d.channelId), d, Some(c))
case Event(c@CurrentBlockCount(count), d: DATA_NORMAL) if d.commitments.timedoutOutgoingHtlcs(count).nonEmpty =>
handleLocalError(HtlcTimedout(d.channelId, d.commitments.timedoutOutgoingHtlcs(count)), d, Some(c))
case Event(c@CurrentFeerates(feeratesPerKw), d: DATA_NORMAL) =>
val networkFeeratePerKw = feeratesPerKw.blocks_2
@ -1023,8 +1023,8 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu
case Event(r: RevocationTimeout, d: DATA_SHUTDOWN) => handleRevocationTimeout(r, d)
case Event(c@CurrentBlockCount(count), d: DATA_SHUTDOWN) if d.commitments.hasTimedoutOutgoingHtlcs(count) =>
handleLocalError(HtlcTimedout(d.channelId), d, Some(c))
case Event(c@CurrentBlockCount(count), d: DATA_SHUTDOWN) if d.commitments.timedoutOutgoingHtlcs(count).nonEmpty =>
handleLocalError(HtlcTimedout(d.channelId, d.commitments.timedoutOutgoingHtlcs(count)), d, Some(c))
case Event(c@CurrentFeerates(feerates), d: DATA_SHUTDOWN) =>
val networkFeeratePerKw = feerates.blocks_2
@ -1204,7 +1204,7 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu
d.commitments.originChannels.get(add.id) match {
case Some(origin) =>
log.info(s"failing htlc #${add.id} paymentHash=${add.paymentHash} origin=$origin: htlc timed out")
relayer ! Status.Failure(AddHtlcFailed(d.channelId, add.paymentHash, HtlcTimedout(d.channelId), origin, None, None))
relayer ! Status.Failure(AddHtlcFailed(d.channelId, add.paymentHash, HtlcTimedout(d.channelId, Set(add)), origin, None, None))
case None =>
// same as for fulfilling the htlc (no big deal)
log.info(s"cannot fail timedout htlc #${add.id} paymentHash=${add.paymentHash} (origin not found)")
@ -1318,11 +1318,11 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu
goto(SYNCING) using d1 sending channelReestablish
case Event(c@CurrentBlockCount(count), d: HasCommitments) if d.commitments.hasTimedoutOutgoingHtlcs(count) =>
case Event(c@CurrentBlockCount(count), d: HasCommitments) if d.commitments.timedoutOutgoingHtlcs(count).nonEmpty =>
// note: this can only happen if state is NORMAL or SHUTDOWN
// -> in NEGOTIATING there are no more htlcs
// -> in CLOSING we either have mutual closed (so no more htlcs), or already have unilaterally closed (so no action required), and we can't be in OFFLINE state anyway
handleLocalError(HtlcTimedout(d.channelId), d, Some(c))
handleLocalError(HtlcTimedout(d.channelId, d.commitments.timedoutOutgoingHtlcs(count)), d, Some(c))
case Event(CMD_UPDATE_RELAY_FEE(feeBaseMsat, feeProportionalMillionths), d: DATA_NORMAL) =>
log.info(s"updating relay fees: prevFeeBaseMsat={} nextFeeBaseMsat={} prevFeeProportionalMillionths={} nextFeeProportionalMillionths={}", d.channelUpdate.feeBaseMsat, feeBaseMsat, d.channelUpdate.feeProportionalMillionths, feeProportionalMillionths)
@ -1446,7 +1446,8 @@ class Channel(val nodeParams: NodeParams, wallet: EclairWallet, remoteNodeId: Pu
goto(NEGOTIATING) using d.copy(closingTxProposed = closingTxProposed1) sending d.localShutdown
}
case Event(c@CurrentBlockCount(count), d: HasCommitments) if d.commitments.hasTimedoutOutgoingHtlcs(count) => handleLocalError(HtlcTimedout(d.channelId), d, Some(c))
case Event(c@CurrentBlockCount(count), d: HasCommitments) if d.commitments.timedoutOutgoingHtlcs(count).nonEmpty =>
handleLocalError(HtlcTimedout(d.channelId, d.commitments.timedoutOutgoingHtlcs(count)), d, Some(c))
// just ignore this, we will put a new watch when we reconnect, and we'll be notified again
case Event(WatchEventConfirmed(BITCOIN_FUNDING_DEPTHOK | BITCOIN_FUNDING_DEEPLYBURIED, _, _), _) => stay

View file

@ -48,7 +48,7 @@ case class ChannelUnavailable (override val channelId: BinaryDa
case class InvalidFinalScript (override val channelId: BinaryData) extends ChannelException(channelId, "invalid final script")
case class FundingTxTimedout (override val channelId: BinaryData) extends ChannelException(channelId, "funding tx timed out")
case class FundingTxSpent (override val channelId: BinaryData, spendingTx: Transaction) extends ChannelException(channelId, s"funding tx has been spent by txid=${spendingTx.txid}")
case class HtlcTimedout (override val channelId: BinaryData) extends ChannelException(channelId, "one or more htlcs timed out")
case class HtlcTimedout (override val channelId: BinaryData, htlcs: Set[UpdateAddHtlc]) extends ChannelException(channelId, s"one or more htlcs timed out: ids=${htlcs.take(10).map(_.id).mkString}") // we only display the first 10 ids
case class HtlcOverridenByLocalCommit (override val channelId: BinaryData) extends ChannelException(channelId, "htlc was overriden by local commit")
case class FeerateTooSmall (override val channelId: BinaryData, remoteFeeratePerKw: Long) extends ChannelException(channelId, s"remote fee rate is too small: remoteFeeratePerKw=$remoteFeeratePerKw")
case class FeerateTooDifferent (override val channelId: BinaryData, localFeeratePerKw: Long, remoteFeeratePerKw: Long) extends ChannelException(channelId, s"local/remote feerates are too different: remoteFeeratePerKw=$remoteFeeratePerKw localFeeratePerKw=$localFeeratePerKw")

View file

@ -61,10 +61,10 @@ case class Commitments(localParams: LocalParams, remoteParams: RemoteParams,
def hasNoPendingHtlcs: Boolean = localCommit.spec.htlcs.isEmpty && remoteCommit.spec.htlcs.isEmpty && remoteNextCommitInfo.isRight
def hasTimedoutOutgoingHtlcs(blockheight: Long): Boolean =
localCommit.spec.htlcs.exists(htlc => htlc.direction == OUT && blockheight >= htlc.add.cltvExpiry) ||
remoteCommit.spec.htlcs.exists(htlc => htlc.direction == IN && blockheight >= htlc.add.cltvExpiry) ||
remoteNextCommitInfo.left.toOption.map(_.nextRemoteCommit.spec.htlcs.exists(htlc => htlc.direction == IN && blockheight >= htlc.add.cltvExpiry)).getOrElse(false)
def timedoutOutgoingHtlcs(blockheight: Long): Set[UpdateAddHtlc] =
(localCommit.spec.htlcs.filter(htlc => htlc.direction == OUT && blockheight >= htlc.add.cltvExpiry) ++
remoteCommit.spec.htlcs.filter(htlc => htlc.direction == IN && blockheight >= htlc.add.cltvExpiry) ++
remoteNextCommitInfo.left.toOption.map(_.nextRemoteCommit.spec.htlcs.filter(htlc => htlc.direction == IN && blockheight >= htlc.add.cltvExpiry)).getOrElse(Set.empty[DirectedHtlc])).map(_.add)
def addLocalProposal(proposal: UpdateMessage): Commitments = Commitments.addLocalProposal(this, proposal)

View file

@ -0,0 +1,87 @@
package fr.acinq.eclair.payment
import akka.actor.{Actor, ActorLogging, ActorRef, Props}
import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.eclair.crypto.Sphinx.ErrorPacket
import fr.acinq.eclair.payment.PaymentLifecycle.{PaymentFailed, PaymentResult, RemoteFailure, SendPayment}
import fr.acinq.eclair.{NodeParams, randomBytes, secureRandom}
import fr.acinq.eclair.router.{Announcements, ChannelDesc, Data}
import fr.acinq.eclair.wire.{ChannelUpdate, UnknownPaymentHash}
import scala.concurrent.duration._
/**
* This actor periodically probes the network by sending payments to random nodes. The payments will eventually fail
* because the recipient doesn't know the preimage, but it allows us to test channels and improve routing for real payments.
*/
class Autoprobe(nodeParams: NodeParams, router: ActorRef, paymentInitiator: ActorRef) extends Actor with ActorLogging {
import Autoprobe._
import scala.concurrent.ExecutionContext.Implicits.global
// refresh our map of channel_updates regularly from the router
context.system.scheduler.schedule(0 seconds, ROUTING_TABLE_REFRESH_INTERVAL, router, 'data)
override def receive: Receive = {
case routingData: Data =>
scheduleProbe()
context become main(routingData)
}
def main(routingData: Data): Receive = {
case routingData: Data =>
context become main(routingData)
case TickProbe =>
pickPaymentDestination(nodeParams.nodeId, routingData) match {
case Some(targetNodeId) =>
val paymentHash = randomBytes(32) // we don't even know the preimage (this needs to be a secure random!)
log.info(s"sending payment probe to node=$targetNodeId payment_hash=$paymentHash")
paymentInitiator ! SendPayment(PAYMENT_AMOUNT_MSAT, paymentHash, targetNodeId, maxAttempts = 1)
case None =>
log.info(s"could not find a destination, re-scheduling")
scheduleProbe()
}
case paymentResult: PaymentResult =>
paymentResult match {
case PaymentFailed(_, _ :+ RemoteFailure(_, ErrorPacket(targetNodeId, UnknownPaymentHash))) =>
log.info(s"payment probe successful to node=$targetNodeId")
case _ =>
log.info(s"payment probe failed with paymentResult=$paymentResult")
}
scheduleProbe()
}
def scheduleProbe() = context.system.scheduler.scheduleOnce(PROBING_INTERVAL, self, TickProbe)
}
object Autoprobe {
def props(nodeParams: NodeParams, router: ActorRef, paymentInitiator: ActorRef) = Props(classOf[Autoprobe], nodeParams, router, paymentInitiator)
val ROUTING_TABLE_REFRESH_INTERVAL = 10 minutes
val PROBING_INTERVAL = 20 seconds
val PAYMENT_AMOUNT_MSAT = 100 * 1000 // this is below dust_limit so there won't be an output in the commitment tx
object TickProbe
def pickPaymentDestination(nodeId: PublicKey, routingData: Data): Option[PublicKey] = {
// we only pick direct peers with enabled public channels
val peers = routingData.updates
.collect {
case (desc, u) if desc.a == nodeId && Announcements.isEnabled(u.channelFlags) && routingData.channels.contains(u.shortChannelId) => desc.b // we only consider outgoing channels that are enabled and announced
}
if (peers.isEmpty) {
None
} else {
peers.drop(secureRandom.nextInt(peers.size)).headOption
}
}
}

View file

@ -9,9 +9,8 @@ import Router._
object Graph {
import DirectedGraph._
case class WeightedNode(key: PublicKey, weight: Long)
case class WeightedPath(path: Seq[GraphEdge], weight: Long)
/**
* This comparator must be consistent with the "equals" behavior, thus for two weighted nodes with
@ -25,6 +24,99 @@ object Graph {
}
}
implicit object PathComparator extends Ordering[WeightedPath] {
override def compare(x: WeightedPath, y: WeightedPath): Int = y.weight.compareTo(x.weight)
}
/**
* Yen's algorithm to find the k-shortest (loopless) paths in a graph, uses dijkstra as search algo. Is guaranteed to terminate finding
* at most @pathsToFind paths sorted by cost (the cheapest is in position 0).
* @param graph
* @param sourceNode
* @param targetNode
* @param amountMsat
* @param pathsToFind
* @return
*/
def yenKshortestPaths(graph: DirectedGraph, sourceNode: PublicKey, targetNode: PublicKey, amountMsat: Long, ignoredEdges: Set[ChannelDesc], extraEdges: Set[GraphEdge], pathsToFind: Int): Seq[WeightedPath] = {
var allSpurPathsFound = false
// stores the shortest paths
val shortestPaths = new mutable.MutableList[WeightedPath]
// stores the candidates for k(K +1) shortest paths, sorted by path cost
val candidates = new mutable.PriorityQueue[WeightedPath]
// find the shortest path, k = 0
val shortestPath = dijkstraShortestPath(graph, sourceNode, targetNode, amountMsat, ignoredEdges, extraEdges)
shortestPaths += WeightedPath(shortestPath, pathCost(shortestPath, amountMsat))
// main loop
for(k <- 1 until pathsToFind) {
if ( !allSpurPathsFound ) {
// for every edge in the path
for (i <- shortestPaths(k - 1).path.indices) {
val prevShortestPath = shortestPaths(k - 1).path
// select the spur node as the i-th element of the k-th previous shortest path (k -1)
val spurEdge = prevShortestPath(i)
// select the subpath from the source to the spur node of the k-th previous shortest path
val rootPathEdges = if(i == 0) prevShortestPath.head :: Nil else prevShortestPath.take(i)
// links to be removed that are part of the previous shortest path and which share the same root path
val edgesToIgnore = shortestPaths.flatMap { weightedPath =>
if ( (i == 0 && (weightedPath.path.head :: Nil) == rootPathEdges) || weightedPath.path.take(i) == rootPathEdges ) {
weightedPath.path(i).desc :: Nil
} else {
Nil
}
}
// find the "spur" path, a subpath going from the spur edge to the target avoiding previously found subpaths
val spurPath = dijkstraShortestPath(graph, spurEdge.desc.a, targetNode, amountMsat, ignoredEdges ++ edgesToIgnore.toSet, extraEdges)
// if there wasn't a path the spur will be empty
if(spurPath.nonEmpty) {
// candidate k-shortest path is made of the rootPath and the new spurPath
val totalPath = rootPathEdges.head.desc.a == spurPath.head.desc.a match {
case true => rootPathEdges.tail ++ spurPath // if the heads are the same node, drop it from the rootPath
case false => rootPathEdges ++ spurPath
}
//val totalPath = concat(rootPathEdges, spurPath.toList)
val candidatePath = WeightedPath(totalPath, pathCost(totalPath, amountMsat))
if (!shortestPaths.contains(candidatePath) && !candidates.exists(_ == candidatePath)) {
candidates.enqueue(candidatePath)
}
}
}
}
if(candidates.isEmpty) {
// handles the case of having exhausted all possible spur paths and it's impossible to reach the target from the source
allSpurPathsFound = true
} else {
// move the best candidate to the shortestPaths container
shortestPaths += candidates.dequeue()
}
}
shortestPaths
}
// Calculates the total cost of a path (amount + fees), direct channels with the source will have a cost of 0 (pay no fees)
def pathCost(path: Seq[GraphEdge], amountMsat: Long): Long = {
path.drop(1).foldRight(amountMsat) { (edge, cost) =>
edgeWeight(edge, cost, isNeighborTarget = false)
}
}
/**
* Finds the shortest path in the graph, uses a modified version of Dijsktra's algorithm that computes
* the shortest path from the target to the source (this is because we want to calculate the weight of the
@ -38,9 +130,6 @@ object Graph {
* @param extraEdges a list of extra edges we want to consider but are not currently in the graph
* @return
*/
def shortestPath(g: DirectedGraph, sourceNode: PublicKey, targetNode: PublicKey, amountMsat: Long, ignoredEdges: Set[ChannelDesc], extraEdges: Set[GraphEdge]): Seq[Hop] = {
dijkstraShortestPath(g, sourceNode, targetNode, amountMsat, ignoredEdges, extraEdges).map(graphEdgeToHop)
}
def dijkstraShortestPath(g: DirectedGraph, sourceNode: PublicKey, targetNode: PublicKey, amountMsat: Long, ignoredEdges: Set[ChannelDesc], extraEdges: Set[GraphEdge]): Seq[GraphEdge] = {

View file

@ -26,7 +26,9 @@ import fr.acinq.eclair.channel._
import fr.acinq.eclair.crypto.TransportHandler
import fr.acinq.eclair.io.Peer.{ChannelClosed, InvalidSignature, NonexistingChannel, PeerRoutingMessage}
import fr.acinq.eclair.payment.PaymentRequest.ExtraHop
import fr.acinq.eclair.router.Graph.GraphStructure.DirectedGraph.graphEdgeToHop
import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge}
import fr.acinq.eclair.router.Graph.WeightedPath
import fr.acinq.eclair.transactions.Scripts
import fr.acinq.eclair.wire._
@ -35,7 +37,7 @@ import scala.collection.{SortedSet, mutable}
import scala.compat.Platform
import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Promise}
import scala.util.Try
import scala.util.{Random, Try}
// @formatter:off
@ -367,6 +369,10 @@ class Router(nodeParams: NodeParams, watcher: ActorRef, initialized: Option[Prom
sender ! (d.updates ++ d.privateUpdates)
stay
case Event('data, d) =>
sender ! d
stay
case Event(RouteRequest(start, end, amount, assistedRoutes, ignoreNodes, ignoreChannels), d) =>
// we convert extra routing info provided in the payment request to fake channel_update
// it takes precedence over all other channel_updates we know
@ -376,7 +382,8 @@ class Router(nodeParams: NodeParams, watcher: ActorRef, initialized: Option[Prom
val ignoredUpdates = getIgnoredChannelDesc(d.updates ++ d.privateUpdates ++ assistedUpdates, ignoreNodes) ++ ignoreChannels ++ d.excludedChannels
log.info(s"finding a route $start->$end with assistedChannels={} ignoreNodes={} ignoreChannels={} excludedChannels={}", assistedUpdates.keys.mkString(","), ignoreNodes.map(_.toBin).mkString(","), ignoreChannels.mkString(","), d.excludedChannels.mkString(","))
val extraEdges = assistedUpdates.map { case (c, u) => GraphEdge(c, u) }.toSet
findRoute(d.graph, start, end, amount, extraEdges = extraEdges, ignoredEdges = ignoredUpdates.toSet)
// we ask the router to make a random selection among the three best routes, numRoutes = 3
findRoute(d.graph, start, end, amount, numRoutes = DEFAULT_ROUTES_COUNT, extraEdges = extraEdges, ignoredEdges = ignoredUpdates.toSet)
.map(r => sender ! RouteResponse(r, ignoreNodes, ignoreChannels))
.recover { case t => sender ! Status.Failure(t) }
stay
@ -797,24 +804,42 @@ object Router {
*/
val ROUTE_MAX_LENGTH = 20
// The default amount of routes we'll search for when findRoute is called
val DEFAULT_ROUTES_COUNT = 3
// The default allowed 'spread' between the cheapest route found an the others
// routes exceeding this difference won't be considered as a valid result
val DEFAULT_ALLOWED_SPREAD = 0.1D
/**
* Find a route in the graph between localNodeId and targetNodeId, returns the route and its cost
* Find a route in the graph between localNodeId and targetNodeId, returns the route.
* Will perform a k-shortest path selection given the @param numRoutes and randomly select one of the result,
* the 'route-set' from where we select the result is made of the k-shortest path given that none of them
* exceeds a 10% spread with the cheapest route
*
* @param g
* @param localNodeId
* @param targetNodeId
* @param amountMsat the amount that will be sent along this route
* @param numRoutes the number of shortest-paths to find
* @param extraEdges a set of extra edges we want to CONSIDER during the search
* @param ignoredEdges a set of extra edges we want to IGNORE during the search
* @return the computed route to the destination @targetNodeId
*/
def findRoute(g: DirectedGraph, localNodeId: PublicKey, targetNodeId: PublicKey, amountMsat: Long, extraEdges: Set[GraphEdge] = Set.empty, ignoredEdges: Set[ChannelDesc] = Set.empty): Try[Seq[Hop]] = Try {
def findRoute(g: DirectedGraph, localNodeId: PublicKey, targetNodeId: PublicKey, amountMsat: Long, numRoutes: Int, extraEdges: Set[GraphEdge] = Set.empty, ignoredEdges: Set[ChannelDesc] = Set.empty): Try[Seq[Hop]] = Try {
if (localNodeId == targetNodeId) throw CannotRouteToSelf
Graph.shortestPath(g, localNodeId, targetNodeId, amountMsat, ignoredEdges, extraEdges) match {
val foundRoutes = Graph.yenKshortestPaths(g, localNodeId, targetNodeId, amountMsat, ignoredEdges, extraEdges, numRoutes).toList match {
case Nil => throw RouteNotFound
case path => path
case route :: Nil if route.path.isEmpty => throw RouteNotFound
case foundRoutes => foundRoutes
}
// minimum cost
val minimumCost = foundRoutes.head.weight
// routes paying at most minimumCost + 10%
val eligibleRoutes = foundRoutes.filter(_.weight <= (minimumCost + minimumCost * DEFAULT_ALLOWED_SPREAD).round)
Random.shuffle(eligibleRoutes).head.path.map(graphEdgeToHop)
}
}

View file

@ -1,4 +1,4 @@
{
"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", "checkinvoice (paymentRequest): returns node, amount and payment hash in an invoice/paymentRequest", "findroute (paymentRequest|nodeId): given a payment request or nodeID checks if there is a valid payment route returns JSON with attempts, nodes and channels of route", "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" ],
"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"
}

View file

@ -27,6 +27,8 @@ class CoinUtilsSpec extends FunSuite {
assert(am_btc == MilliSatoshi(100000000000L))
val am_mbtc: MilliSatoshi = CoinUtils.convertStringAmountToMsat("1", MBtcUnit.code)
assert(am_mbtc == MilliSatoshi(100000000L))
val am_bits: MilliSatoshi = CoinUtils.convertStringAmountToMsat("1", BitUnit.code)
assert(am_bits == MilliSatoshi(100000))
val am_sat: MilliSatoshi = CoinUtils.convertStringAmountToMsat("1", SatUnit.code)
assert(am_sat == MilliSatoshi(1000))
val am_msat: MilliSatoshi = CoinUtils.convertStringAmountToMsat("1", MSatUnit.code)
@ -42,6 +44,8 @@ class CoinUtilsSpec extends FunSuite {
assert(am_mbtc_dec_nozero == MilliSatoshi(25000000L))
val am_mbtc_dec: MilliSatoshi = CoinUtils.convertStringAmountToMsat("1.23456789", MBtcUnit.code)
assert(am_mbtc_dec == MilliSatoshi(123456789L))
val am_bits_dec: MilliSatoshi = CoinUtils.convertStringAmountToMsat("1.23456789", BitUnit.code)
assert(am_bits_dec == MilliSatoshi(123456))
val am_sat_dec: MilliSatoshi = CoinUtils.convertStringAmountToMsat("1.23456789", SatUnit.code)
assert(am_sat_dec == MilliSatoshi(1234))
val am_msat_dec: MilliSatoshi = CoinUtils.convertStringAmountToMsat("1.234", MSatUnit.code)
@ -80,19 +84,23 @@ class CoinUtilsSpec extends FunSuite {
assert(CoinUtils.rawAmountInUnit(MilliSatoshi(123), SatUnit) == BigDecimal(0.123))
assert(CoinUtils.rawAmountInUnit(MilliSatoshi(123), MSatUnit) == BigDecimal(123))
assert(CoinUtils.rawAmountInUnit(MilliSatoshi(12345678), BtcUnit) == BigDecimal(0.00012345678))
assert(CoinUtils.rawAmountInUnit(MilliSatoshi(1234567), BitUnit) == BigDecimal(12.34567))
assert(CoinUtils.rawAmountInUnit(Satoshi(123), BtcUnit) == BigDecimal(0.00000123))
assert(CoinUtils.rawAmountInUnit(Satoshi(123), MBtcUnit) == BigDecimal(0.00123))
assert(CoinUtils.rawAmountInUnit(Satoshi(123), BitUnit) == BigDecimal(1.23))
assert(CoinUtils.rawAmountInUnit(Satoshi(123), SatUnit) == BigDecimal(123))
assert(CoinUtils.rawAmountInUnit(Satoshi(123), MSatUnit) == BigDecimal(123000))
assert(CoinUtils.rawAmountInUnit(MilliBtc(123.456), BtcUnit) == BigDecimal(0.123456))
assert(CoinUtils.rawAmountInUnit(MilliBtc(123.456), MBtcUnit) == BigDecimal(123.456))
assert(CoinUtils.rawAmountInUnit(MilliBtc(123.45678), BitUnit) == BigDecimal(123456.78))
assert(CoinUtils.rawAmountInUnit(MilliBtc(123.456789), SatUnit) == BigDecimal(12345678.9))
assert(CoinUtils.rawAmountInUnit(MilliBtc(123.45678987), MSatUnit) == BigDecimal(12345678987L))
assert(CoinUtils.rawAmountInUnit(Btc(123.456), BtcUnit) == BigDecimal(123.456))
assert(CoinUtils.rawAmountInUnit(Btc(123.45678987654), MBtcUnit) == BigDecimal(123456.78987654))
assert(CoinUtils.rawAmountInUnit(Btc(123.456789876), BitUnit) == BigDecimal(123456789.876))
assert(CoinUtils.rawAmountInUnit(Btc(1.22233333444), SatUnit) == BigDecimal(122233333.444))
assert(CoinUtils.rawAmountInUnit(Btc(0.00011111222), MSatUnit) == BigDecimal(11111222L))
}

View file

@ -0,0 +1,52 @@
package fr.acinq.eclair
import java.io.{File, IOException}
import java.nio.file.attribute.BasicFileAttributes
import java.nio.file._
import com.typesafe.config.{Config, ConfigFactory}
import fr.acinq.bitcoin.Block
import fr.acinq.eclair.crypto.LocalKeyManager
import org.scalatest.FunSuite
import scala.util.Try
class StartupSpec extends FunSuite {
test("NodeParams should fail if the alias is illegal (over 32 bytes)") {
val threeBytesUTFChar = '\u20AC' //
val baseUkraineAlias = "BitcoinLightningNodeUkraine"
assert(baseUkraineAlias.length === 27)
assert(baseUkraineAlias.getBytes.length === 27)
// we add 2 UTF-8 chars, each is 3-bytes long -> total new length 33 bytes!
val goUkraineGo = threeBytesUTFChar+"BitcoinLightningNodeUkraine"+threeBytesUTFChar
assert(goUkraineGo.length === 29)
assert(goUkraineGo.getBytes.length === 33) // too long for the alias, should be truncated
val illegalAliasConf = ConfigFactory.parseString(s"node-alias = $goUkraineGo")
val conf = illegalAliasConf.withFallback(ConfigFactory.parseResources("reference.conf").getConfig("eclair"))
val tempConfParentDir = new File("temp-test.conf")
val keyManager = new LocalKeyManager(seed = randomKey.toBin, chainHash = Block.TestnetGenesisBlock.hash)
// try to create a NodeParams instance with a conf that contains an illegal alias
val nodeParamsAttempt = Try(NodeParams.makeNodeParams(tempConfParentDir, conf, keyManager))
assert(nodeParamsAttempt.isFailure && nodeParamsAttempt.failed.get.getMessage.contains("alias, too long"))
// destroy conf files after the test
Files.walkFileTree(tempConfParentDir.toPath, new SimpleFileVisitor[Path]() {
override def visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult = {
Files.deleteIfExists(file)
FileVisitResult.CONTINUE
}
})
tempConfParentDir.listFiles.foreach(_.delete())
tempConfParentDir.deleteOnExit()
}
}

View file

@ -19,6 +19,7 @@ package fr.acinq.eclair.api
import java.net.{InetAddress, InetSocketAddress}
import fr.acinq.bitcoin.{BinaryData, OutPoint}
import fr.acinq.eclair.payment.PaymentRequest
import fr.acinq.eclair.transactions.{IN, OUT}
import fr.acinq.eclair.wire.NodeAddress
import org.json4s.jackson.Serialization
@ -61,4 +62,9 @@ class JsonSerializersSpec extends FunSuite with Matchers {
Serialization.write(OUT)(org.json4s.DefaultFormats + new DirectionSerializer) shouldBe s""""OUT""""
}
test("Payment Request") {
val ref = "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp"
val pr = PaymentRequest.read(ref)
Serialization.write(pr)(org.json4s.DefaultFormats + new PaymentRequestSerializer) shouldBe """{"prefix":"lnbc","amount":250000000,"timestamp":1496314658,"nodeId":"03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad","description":"1 cup coffee","paymentHash":"0001020304050607080900010203040506070809000102030405060708090102","expiry":60,"minFinalCltvExpiry":null}"""
}
}

View file

@ -322,10 +322,10 @@ class RelayerSpec extends TestkitBaseClass {
sender.send(relayer, Status.Failure(AddHtlcFailed(channelId_bc, paymentHash, ChannelUnavailable(channelId_bc), origin, Some(channelUpdate_bc_disabled), None)))
assert(register.expectMsgType[Register.Forward[CMD_FAIL_HTLC]].message.reason === Right(ChannelDisabled(channelUpdate_bc_disabled.messageFlags, channelUpdate_bc_disabled.channelFlags, channelUpdate_bc_disabled)))
sender.send(relayer, Status.Failure(AddHtlcFailed(channelId_bc, paymentHash, HtlcTimedout(channelId_bc), origin, None, None)))
sender.send(relayer, Status.Failure(AddHtlcFailed(channelId_bc, paymentHash, HtlcTimedout(channelId_bc, Set.empty), origin, None, None)))
assert(register.expectMsgType[Register.Forward[CMD_FAIL_HTLC]].message.reason === Right(PermanentChannelFailure))
sender.send(relayer, Status.Failure(AddHtlcFailed(channelId_bc, paymentHash, HtlcTimedout(channelId_bc), origin, Some(channelUpdate_bc), None)))
sender.send(relayer, Status.Failure(AddHtlcFailed(channelId_bc, paymentHash, HtlcTimedout(channelId_bc, Set.empty), origin, Some(channelUpdate_bc), None)))
assert(register.expectMsgType[Register.Forward[CMD_FAIL_HTLC]].message.reason === Right(PermanentChannelFailure))
register.expectNoMsg(100 millis)

View file

@ -19,11 +19,11 @@ package fr.acinq.eclair.router
import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey}
import fr.acinq.bitcoin.{BinaryData, Block, Crypto}
import fr.acinq.eclair.payment.PaymentRequest.ExtraHop
import fr.acinq.eclair.router.Graph.GraphStructure.DirectedGraph.graphEdgeToHop
import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge}
import fr.acinq.eclair.wire._
import fr.acinq.eclair.{ShortChannelId, nodeFee, randomKey}
import fr.acinq.eclair.{ShortChannelId, randomKey}
import org.scalatest.FunSuite
import scala.util.{Failure, Success}
/**
@ -36,14 +36,6 @@ class RouteCalculationSpec extends FunSuite {
val (a, b, c, d, e) = (randomKey.publicKey, randomKey.publicKey, randomKey.publicKey, randomKey.publicKey, randomKey.publicKey)
// the total fee cost for this path
def pathCost(path: Seq[Hop], amountMsat: Long): Long = {
path.drop(1).reverse.foldLeft(amountMsat) { (fee, hop) =>
fee + nodeFee(hop.lastUpdate.feeBaseMsat, hop.lastUpdate.feeProportionalMillionths, fee)
}
}
test("calculate simple route") {
val updates = List(
@ -55,7 +47,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil))
}
@ -93,16 +85,16 @@ class RouteCalculationSpec extends FunSuite {
val graph = makeGraph(updates)
val Success(route) = Router.findRoute(graph, a, d, amountMsat)
val Success(route) = Router.findRoute(graph, a, d, amountMsat, numRoutes = 1)
assert(hops2Ids(route) === 4 :: 5 :: 6 :: Nil)
assert(pathCost(route, amountMsat) === expectedCost)
assert(Graph.pathCost(hops2Edges(route), amountMsat) === expectedCost)
// now channel 5 could route the amount (10000) but not the amount + fees (10007)
val (desc, update) = makeUpdate(5L, e, f, feeBaseMsat = 1, feeProportionalMillionth = 400, minHtlcMsat = 0, maxHtlcMsat = Some(10005))
val graph1 = graph.addEdge(desc, update)
val Success(route1) = Router.findRoute(graph1, a, d, amountMsat)
val Success(route1) = Router.findRoute(graph1, a, d, amountMsat, numRoutes = 1)
assert(hops2Ids(route1) === 1 :: 2 :: 3 :: Nil)
}
@ -117,7 +109,7 @@ class RouteCalculationSpec extends FunSuite {
).toMap
val g = makeGraph(updates)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Success(2 :: 5 :: Nil))
}
@ -133,11 +125,11 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT)
val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route1.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil))
val graphWithRemovedEdge = g.removeEdge(ChannelDesc(ShortChannelId(3L), c, d))
val route2 = Router.findRoute(graphWithRemovedEdge, a, e, DEFAULT_AMOUNT_MSAT)
val route2 = Router.findRoute(graphWithRemovedEdge, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route2.map(hops2Ids) === Failure(RouteNotFound))
}
@ -159,7 +151,7 @@ class RouteCalculationSpec extends FunSuite {
val graph = makeGraph(updates)
val route = Router.findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Success(4 :: 3 :: Nil))
}
@ -182,7 +174,7 @@ class RouteCalculationSpec extends FunSuite {
val graph = makeGraph(updates)
val route = Router.findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Success(4 :: 3 :: Nil))
}
@ -204,7 +196,7 @@ class RouteCalculationSpec extends FunSuite {
val graph = makeGraph(updates)
val route = Router.findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Success(1 :: 6 :: 3 :: Nil))
}
@ -220,7 +212,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil))
}
@ -233,7 +225,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Failure(RouteNotFound))
}
@ -247,7 +239,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Failure(RouteNotFound))
}
@ -260,7 +252,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates).addVertex(a)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Failure(RouteNotFound))
}
@ -274,7 +266,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Failure(RouteNotFound))
}
@ -288,7 +280,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Failure(RouteNotFound))
}
@ -304,7 +296,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route = Router.findRoute(g, a, d, highAmount)
val route = Router.findRoute(g, a, d, highAmount, numRoutes = 1)
assert(route.map(hops2Ids) === Failure(RouteNotFound))
}
@ -321,7 +313,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route = Router.findRoute(g, a, d, lowAmount)
val route = Router.findRoute(g, a, d, lowAmount, numRoutes = 1)
assert(route.map(hops2Ids) === Failure(RouteNotFound))
}
@ -336,7 +328,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route = Router.findRoute(g, a, a, DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(g, a, a, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Failure(CannotRouteToSelf))
}
@ -351,7 +343,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route = Router.findRoute(g, a, b, DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(g, a, b, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Success(1 :: Nil))
}
@ -367,10 +359,10 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT)
val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route1.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil))
val route2 = Router.findRoute(g, e, a, DEFAULT_AMOUNT_MSAT)
val route2 = Router.findRoute(g, e, a, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route2.map(hops2Ids) === Failure(RouteNotFound))
}
@ -407,7 +399,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val hops = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT).get
val hops = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1).get
assert(hops === Hop(a, b, uab) :: Hop(b, c, ubc) :: Hop(c, d, ucd) :: Hop(d, e, ude) :: Nil)
}
@ -447,7 +439,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, ignoredEdges = Set(ChannelDesc(ShortChannelId(3L), c, d)))
val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1 , ignoredEdges = Set(ChannelDesc(ShortChannelId(3L), c, d)))
assert(route1.map(hops2Ids) === Failure(RouteNotFound))
// verify that we left the graph untouched
@ -456,7 +448,7 @@ class RouteCalculationSpec extends FunSuite {
assert(g.containsVertex(d))
// make sure we can find a route if without the blacklist
val route2 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT)
val route2 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route2.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil))
}
@ -469,14 +461,14 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Failure(RouteNotFound))
// now we add the missing edge to reach the destination
val (extraDesc, extraUpdate) = makeUpdate(4L, d, e, 5, 5)
val extraGraphEdges = Set(GraphEdge(extraDesc, extraUpdate))
val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, extraEdges = extraGraphEdges)
val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1, extraEdges = extraGraphEdges)
assert(route1.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil))
}
@ -491,7 +483,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT)
val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route1.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil))
assert(route1.get(1).lastUpdate.feeBaseMsat == 10)
@ -499,7 +491,7 @@ class RouteCalculationSpec extends FunSuite {
val extraGraphEdges = Set(GraphEdge(extraDesc, extraUpdate))
val route2 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, extraEdges = extraGraphEdges)
val route2 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1, extraEdges = extraGraphEdges)
assert(route2.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil))
assert(route2.get(1).lastUpdate.feeBaseMsat == 5)
}
@ -557,10 +549,10 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
assert(Router.findRoute(g, nodes(0), nodes(18), DEFAULT_AMOUNT_MSAT).map(hops2Ids) === Success(0 until 18))
assert(Router.findRoute(g, nodes(0), nodes(19), DEFAULT_AMOUNT_MSAT).map(hops2Ids) === Success(0 until 19))
assert(Router.findRoute(g, nodes(0), nodes(20), DEFAULT_AMOUNT_MSAT).map(hops2Ids) === Success(0 until 20))
assert(Router.findRoute(g, nodes(0), nodes(21), DEFAULT_AMOUNT_MSAT).map(hops2Ids) === Failure(RouteNotFound))
assert(Router.findRoute(g, nodes(0), nodes(18), DEFAULT_AMOUNT_MSAT, numRoutes = 1).map(hops2Ids) === Success(0 until 18))
assert(Router.findRoute(g, nodes(0), nodes(19), DEFAULT_AMOUNT_MSAT, numRoutes = 1).map(hops2Ids) === Success(0 until 19))
assert(Router.findRoute(g, nodes(0), nodes(20), DEFAULT_AMOUNT_MSAT, numRoutes = 1).map(hops2Ids) === Success(0 until 20))
assert(Router.findRoute(g, nodes(0), nodes(21), DEFAULT_AMOUNT_MSAT, numRoutes = 1).map(hops2Ids) === Failure(RouteNotFound))
}
test("ignore cheaper route when it has more than 20 hops") {
@ -577,7 +569,7 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates2)
val route = Router.findRoute(g, nodes(0), nodes(49), DEFAULT_AMOUNT_MSAT)
val route = Router.findRoute(g, nodes(0), nodes(49), DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route.map(hops2Ids) === Success(0 :: 1 :: 99 :: 48 :: Nil))
}
@ -593,10 +585,145 @@ class RouteCalculationSpec extends FunSuite {
val g = makeGraph(updates)
val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT)
val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1)
assert(route1.map(hops2Ids) === Success(1 :: 2 :: 4 :: 5 :: Nil))
}
/**
*
* +---+ +---+ +---+
* | A +-----+ | B +----------> | C |
* +-+-+ | +-+-+ +-+-+
* ^ | ^ |
* | | | |
* | v----> + | |
* +-+-+ <-+-+ +-+-+
* | D +----------> | E +----------> | F |
* +---+ +---+ +---+
*
*/
test("find the k-shortest paths in a graph, k=4") {
val (a, b, c, d, e, f) = (
PublicKey("02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), //a
PublicKey("03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), //b
PublicKey("0358e32d245ff5f5a3eb14c78c6f69c67cea7846bdf9aeeb7199e8f6fbb0306484"), //c
PublicKey("029e059b6780f155f38e83601969919aae631ddf6faed58fe860c72225eb327d7c"), //d
PublicKey("02f38f4e37142cc05df44683a83e22dea608cf4691492829ff4cf99888c5ec2d3a"), //e
PublicKey("03fc5b91ce2d857f146fd9b986363374ffe04dc143d8bcd6d7664c8873c463cdfc") //f
)
val edges = Seq(
makeUpdate(1L, d, a, 1, 0),
makeUpdate(2L, d, e, 1, 0),
makeUpdate(3L, a, e, 1, 0),
makeUpdate(4L, e, b, 1, 0),
makeUpdate(5L, e, f, 1, 0),
makeUpdate(6L, b, c, 1, 0),
makeUpdate(7L, c, f, 1, 0)
).toMap
val graph = DirectedGraph.makeGraph(edges)
val fourShortestPaths = Graph.yenKshortestPaths(graph, d, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, pathsToFind = 4)
assert(fourShortestPaths.size === 4)
assert(hops2Ids(fourShortestPaths(0).path.map(graphEdgeToHop)) === 2 :: 5 :: Nil) // D -> E -> F
assert(hops2Ids(fourShortestPaths(1).path.map(graphEdgeToHop)) === 1 :: 3 :: 5 :: Nil) // D -> A -> E -> F
assert(hops2Ids(fourShortestPaths(2).path.map(graphEdgeToHop)) === 2 :: 4 :: 6 :: 7 :: Nil) // D -> E -> B -> C -> F
assert(hops2Ids(fourShortestPaths(3).path.map(graphEdgeToHop)) === 1 :: 3 :: 4 :: 6 :: 7 :: Nil) // D -> A -> E -> B -> C -> F
}
test("find the k shortest path (wikipedia example)") {
val (c, d, e, f, g, h) = (
PublicKey("02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), //c
PublicKey("03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), //d
PublicKey("0358e32d245ff5f5a3eb14c78c6f69c67cea7846bdf9aeeb7199e8f6fbb0306484"), //e
PublicKey("029e059b6780f155f38e83601969919aae631ddf6faed58fe860c72225eb327d7c"), //f
PublicKey("02f38f4e37142cc05df44683a83e22dea608cf4691492829ff4cf99888c5ec2d3a"), //g
PublicKey("03fc5b91ce2d857f146fd9b986363374ffe04dc143d8bcd6d7664c8873c463cdfc") //h
)
val edges = Seq(
makeUpdate(10L, c, e, 2, 0),
makeUpdate(20L, c, d, 3, 0),
makeUpdate(30L, d, f, 4, 5), // D- > F has a higher cost to distinguish it from the 2nd cheapest route
makeUpdate(40L, e, d, 1, 0),
makeUpdate(50L, e, f, 2, 0),
makeUpdate(60L, e, g, 3, 0),
makeUpdate(70L, f, g, 2, 0),
makeUpdate(80L, f, h, 1, 0),
makeUpdate(90L, g, h, 2, 0)
)
val graph = DirectedGraph().addEdges(edges)
val twoShortestPaths = Graph.yenKshortestPaths(graph, c, h, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, pathsToFind = 2)
assert(twoShortestPaths.size === 2)
val shortest = twoShortestPaths(0)
assert(hops2Ids(shortest.path.map(graphEdgeToHop)) === 10 :: 50 :: 80 :: Nil) // C -> E -> F -> H
val secondShortest = twoShortestPaths(1)
assert(hops2Ids(secondShortest.path.map(graphEdgeToHop)) === 10 :: 60 :: 90 :: Nil) // C -> E -> G -> H
}
test("terminate looking for k-shortest path if there are no more alternative paths than k"){
val f = randomKey.publicKey
// simple graph with only 2 possible paths from A to F
val edges = Seq(
makeUpdate(1L, a, b, 1, 0),
makeUpdate(2L, b, c, 1, 0),
makeUpdate(3L, c, f, 1, 0),
makeUpdate(4L, c, d, 1, 0),
makeUpdate(5L, d, e, 1, 0),
makeUpdate(6L, e, f, 1, 0)
)
val graph = DirectedGraph().addEdges(edges)
//we ask for 3 shortest paths but only 2 can be found
val foundPaths = Graph.yenKshortestPaths(graph, a, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, pathsToFind = 3)
assert(foundPaths.size === 2)
assert(hops2Ids(foundPaths(0).path.map(graphEdgeToHop)) === 1 :: 2 :: 3 :: Nil) // A -> B -> C -> F
assert(hops2Ids(foundPaths(1).path.map(graphEdgeToHop)) === 1 :: 2 :: 4 :: 5 :: 6 :: Nil) // A -> B -> C -> D -> E -> F
}
test("select a random route below the allowed fee spread") {
val f = randomKey.publicKey
// A -> B -> C -> D has total cost of 10000005
// A -> E -> C -> D has total cost of 11080003 !!
// A -> E -> F -> D has total cost of 10000006
val g = makeGraph(List(
makeUpdate(1L, a, b, feeBaseMsat = 1, 0),
makeUpdate(4L, a, e, feeBaseMsat = 1, 0),
makeUpdate(2L, b, c, feeBaseMsat = 2, 0),
makeUpdate(3L, c, d, feeBaseMsat = 3, 0),
makeUpdate(5L, e, f, feeBaseMsat = 3, 0),
makeUpdate(6L, f, d, feeBaseMsat = 3, 0),
makeUpdate(7L, e, c, feeBaseMsat = 90000, 99000)
).toMap)
(for { _ <- 0 to 10 } yield Router.findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, numRoutes = 3)).map {
case Failure(_) => assert(false)
case Success(someRoute) =>
val routeCost = Graph.pathCost(hops2Edges(someRoute), DEFAULT_AMOUNT_MSAT)
val allowedSpread = DEFAULT_AMOUNT_MSAT + (DEFAULT_AMOUNT_MSAT * Router.DEFAULT_ALLOWED_SPREAD)
// over the three routes we could only get the 2 cheapest because the third is too expensive (over 10% of the cheapest)
assert(routeCost == 10000005 || routeCost == 10000006)
assert(routeCost < allowedSpread)
}
}
}
object RouteCalculationSpec {
@ -632,4 +759,6 @@ object RouteCalculationSpec {
def hops2Ids(route: Seq[Hop]) = route.map(hop => hop.lastUpdate.shortChannelId.toLong)
def hops2Edges(route: Seq[Hop]) = route.map(hop => GraphEdge(ChannelDesc(hop.lastUpdate.shortChannelId, hop.nodeId, hop.nextNodeId), hop.lastUpdate))
}

View file

@ -15,10 +15,26 @@
-fx-font-weight: bold;
}
.text-xs {
-fx-font-size: 10px;
}
.text-sm {
-fx-font-size: 12px;
}
.text-md {
-fx-font-size: 14px;
}
.text-lg {
-fx-font-size: 16px;
}
.text-xl {
-fx-font-size: 18px;
}
.text-error {
-fx-text-fill: rgb(216,31,74);
-fx-font-size: 11px;
@ -115,15 +131,13 @@
/* ---------- Progress Bar ---------- */
.bar {
-fx-background-color: rgb(63,179,234);
-fx-background-color: rgb(114,193,229);
-fx-background-insets: 0;
-fx-background-radius: 0;
}
.track {
-fx-background-color: rgb(206,230,255);
-fx-background-color: rgb(211,227,234);
-fx-background-insets: 0;
-fx-background-radius: 0;
}
/* ---------- Forms ----------- */

View file

@ -1,8 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import java.net.URL?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ProgressBar?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.ColumnConstraints?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.RowConstraints?>
<?import javafx.scene.layout.VBox?>
<!--
~ Copyright 2018 ACINQ SAS
~
@ -19,67 +28,66 @@
~ limitations under the License.
-->
<VBox fx:id="root" styleClass="channel" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1"
onContextMenuRequested="#openChannelContext">
<VBox fx:id="root" onContextMenuRequested="#openChannelContext" styleClass="channel" xmlns="http://javafx.com/javafx/8.0.172-ea" xmlns:fx="http://javafx.com/fxml/1">
<stylesheets>
<URL value="@../commons/globals.css"/>
<URL value="@./main.css"/>
<URL value="@../commons/globals.css" />
<URL value="@./main.css" />
</stylesheets>
<GridPane styleClass="grid" prefWidth="400.0">
<GridPane prefWidth="600.0" styleClass="grid">
<columnConstraints>
<ColumnConstraints hgrow="SOMETIMES" minWidth="140.0" prefWidth="150.0" maxWidth="180.0"/>
<ColumnConstraints hgrow="ALWAYS" minWidth="30.0" prefWidth="30.0"/>
<ColumnConstraints hgrow="SOMETIMES" minWidth="40.0" prefWidth="60.0" maxWidth="60.0"/>
<ColumnConstraints hgrow="ALWAYS" minWidth="30.0" prefWidth="40.0"/>
<ColumnConstraints hgrow="SOMETIMES" maxWidth="380.0" minWidth="100.0" prefWidth="100.0" />
<ColumnConstraints hgrow="NEVER" maxWidth="1.0" minWidth="1.0" prefWidth="1.0" />
<ColumnConstraints hgrow="NEVER" maxWidth="90.0" minWidth="90.0" prefWidth="90.0" />
<ColumnConstraints hgrow="SOMETIMES" prefWidth="100.0" />
<ColumnConstraints hgrow="NEVER" maxWidth="50.0" minWidth="50.0" prefWidth="50.0" />
<ColumnConstraints hgrow="NEVER" />
</columnConstraints>
<rowConstraints>
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
<RowConstraints minHeight="4.0" vgrow="SOMETIMES"/>
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
<RowConstraints minHeight="10.0" vgrow="SOMETIMES"/>
<RowConstraints minHeight="4.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="4.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="4.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="4.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="4.0" vgrow="SOMETIMES" />
</rowConstraints>
<HBox GridPane.columnSpan="4" GridPane.columnIndex="0" alignment="CENTER" spacing="5.0">
<TextField fx:id="channelId" text="N/A" editable="false" styleClass="noteditable, text-strong"
HBox.hgrow="ALWAYS" GridPane.valignment="BOTTOM" focusTraversable="false"/>
<HBox GridPane.columnIndex="4" GridPane.halignment="RIGHT" alignment="CENTER_RIGHT" HBox.hgrow="NEVER" spacing="5">
<Button fx:id="close" mnemonicParsing="false" styleClass="close-channel" text="Close" visible="false"/>
<Button fx:id="forceclose" mnemonicParsing="false" styleClass="forceclose-channel" text="Force close" visible="false"/>
</HBox>
<VBox alignment="CENTER_RIGHT" GridPane.columnIndex="0" GridPane.rowIndex="0" GridPane.rowSpan="2147483647">
<Label styleClass="text-muted, text-xs" text="BALANCE" />
<Label fx:id="amountUs" alignment="CENTER_RIGHT" styleClass="text-lg, channel-balance" text="N/A" />
<ProgressBar fx:id="balanceBar" maxWidth="120.0" minHeight="4.0" prefHeight="4.0" progress="0.0" snapToPixel="false">
<VBox.margin>
<Insets top="3.0" />
</VBox.margin>
</ProgressBar>
</VBox>
<HBox alignment="BOTTOM_LEFT" spacing="5" GridPane.columnIndex="2" GridPane.columnSpan="3" GridPane.rowIndex="0" GridPane.valignment="BOTTOM">
<Label styleClass="text-strong, text-md" text="With" />
<Label fx:id="nodeAlias" maxWidth="120.0" styleClass="text-md, channel-peer-alias" visible="false"/>
<TextField fx:id="nodeId" editable="false" focusTraversable="false" styleClass="noteditable, text-strong, text-md" text="N/A" HBox.hgrow="ALWAYS" />
</HBox>
<ProgressBar fx:id="balanceBar" minHeight="4.0" prefHeight="4.0" maxWidth="1.7976931348623157E308"
progress="0.0" snapToPixel="false"
GridPane.columnSpan="4" GridPane.hgrow="ALWAYS" GridPane.rowIndex="1"/>
<HBox alignment="CENTER_RIGHT" spacing="5" GridPane.columnIndex="5" GridPane.halignment="RIGHT" GridPane.rowIndex="0" HBox.hgrow="NEVER">
<Button fx:id="close" mnemonicParsing="false" styleClass="close-channel" text="Close" visible="false" />
<Button fx:id="forceclose" mnemonicParsing="false" styleClass="forceclose-channel" text="Force close" visible="false" />
</HBox>
<Label styleClass="text-muted" text="Funding tx id" GridPane.columnIndex="0" GridPane.rowIndex="2"/>
<TextField fx:id="txId" text="N/A" focusTraversable="false" editable="false" styleClass="noteditable"
GridPane.columnIndex="1" GridPane.columnSpan="3" GridPane.rowIndex="2"/>
<Label styleClass="text-muted" text="State" GridPane.columnIndex="2" GridPane.rowIndex="1" />
<TextField fx:id="state" editable="false" focusTraversable="false" styleClass="noteditable" text="N/A" GridPane.columnIndex="3" GridPane.columnSpan="3" GridPane.rowIndex="1" />
<Label styleClass="text-muted" text="Remote node id" GridPane.columnIndex="0" GridPane.rowIndex="3"/>
<TextField fx:id="nodeId" text="N/A" focusTraversable="false" editable="false" styleClass="noteditable"
GridPane.columnIndex="1" GridPane.columnSpan="3" GridPane.rowIndex="3"/>
<Label styleClass="text-muted" text="Funder" GridPane.columnIndex="4" GridPane.halignment="RIGHT" GridPane.rowIndex="4" />
<TextField fx:id="funder" editable="false" focusTraversable="false" styleClass="noteditable" text="N/A" GridPane.columnIndex="5" GridPane.rowIndex="4" />
<Label styleClass="text-muted" text="Your balance" GridPane.rowIndex="4"/>
<TextField fx:id="amountUs" text="N/A" focusTraversable="false" editable="false"
styleClass="noteditable"
GridPane.columnIndex="1" GridPane.rowIndex="4"/>
<Label styleClass="text-muted" text="Channel id" GridPane.columnIndex="2" GridPane.rowIndex="2" />
<TextField fx:id="channelId" editable="false" focusTraversable="false" styleClass="noteditable" text="N/A" GridPane.columnIndex="3" GridPane.columnSpan="3" GridPane.rowIndex="2" />
<Label styleClass="text-muted" text="Capacity" GridPane.rowIndex="5"/>
<TextField fx:id="capacity" text="N/A" focusTraversable="false" editable="false"
styleClass="noteditable"
GridPane.columnIndex="1" GridPane.rowIndex="5"/>
<Label styleClass="text-muted" text="Short channel id" GridPane.columnIndex="2" GridPane.rowIndex="3" />
<TextField fx:id="shortChannelId" editable="false" focusTraversable="false" styleClass="noteditable" text="N/A" GridPane.columnIndex="3" GridPane.columnSpan="3" GridPane.rowIndex="3" />
<Label styleClass="text-muted" text="Funder" GridPane.columnIndex="2" GridPane.rowIndex="4"/>
<TextField fx:id="funder" text="N/A" focusTraversable="false" editable="false" styleClass="noteditable"
GridPane.columnIndex="3" GridPane.rowIndex="4"/>
<Label styleClass="text-muted" text="Funding tx id" GridPane.columnIndex="2" GridPane.rowIndex="4" />
<TextField fx:id="txId" editable="false" focusTraversable="false" styleClass="noteditable" text="N/A" GridPane.columnIndex="3" GridPane.rowIndex="4" />
<Label styleClass="text-muted" text="State" GridPane.columnIndex="2" GridPane.rowIndex="5"/>
<TextField fx:id="state" text="N/A" focusTraversable="false" editable="false" styleClass="noteditable"
GridPane.columnIndex="3" GridPane.rowIndex="5"/>
<HBox prefHeight="100.0" prefWidth="1.0" styleClass="channel-balance-separator" GridPane.columnIndex="1" GridPane.rowSpan="2147483647" />
</GridPane>
<HBox styleClass="channel-separator"/>
<HBox styleClass="channel-separator" />
</VBox>

View file

@ -1,7 +1,7 @@
/* ---------- Status Bar ---------- */
.status-bar {
-fx-padding: .5em 1em;
-fx-padding: .5em .7em;
-fx-background-color: rgb(221,221,221);
-fx-border-width: 1px 0 0 0;
-fx-border-color: rgb(181,181,181);
@ -50,18 +50,28 @@
}
.channel .grid {
-fx-padding: 10px;
-fx-padding: 14px;
-fx-vgap: 3px;
-fx-hgap: 5px;
-fx-hgap: 1em;
-fx-font-size: 12px;
}
.channel-separator {
-fx-background-color: rgb(220,220,220);
-fx-background-color: rgb(190,200,210);
-fx-pref-height: 1px;
-fx-padding: 0 -.25em;
}
.channel-balance-separator {
-fx-background-color: rgb(225,230,235);
}
.channel-peer-alias {
-fx-background-color: rgb(232,233,235);
-fx-background-radius: 2;
-fx-label-padding: 0 3px;
}
.channels-info {
-fx-padding: 4em 0 0 0;
}

View file

@ -209,51 +209,40 @@
</center>
<bottom>
<HBox fx:id="statusBarBox" styleClass="status-bar" spacing="10">
<children>
<HBox alignment="CENTER_LEFT" HBox.hgrow="ALWAYS" onContextMenuRequested="#openNodeIdContext">
<children>
<ImageView fitHeight="16.0" fitWidth="27.0" opacity="0.52" pickOnBounds="true"
preserveRatio="true">
<image>
<Image url="@../commons/images/eclair-shape.png"/>
</image>
</ImageView>
<Label fx:id="labelNodeId" text="N/A"/>
</children>
</HBox>
<HBox alignment="CENTER_LEFT" HBox.hgrow="SOMETIMES" minWidth="80.0">
<children>
<Separator orientation="VERTICAL"/>
<Rectangle fx:id="rectRGB" width="7" height="7" fill="transparent"/>
<Label fx:id="labelAlias" text="N/A"/>
</children>
</HBox>
<HBox alignment="CENTER_LEFT" HBox.hgrow="NEVER" minWidth="85.0">
<children>
<Separator orientation="VERTICAL"/>
<Label text="HTTP" styleClass="badge, badge-http"/>
<Label fx:id="labelApi" styleClass="value" text="N/A"/>
</children>
</HBox>
<HBox alignment="CENTER_LEFT" HBox.hgrow="NEVER" minWidth="85.0">
<children>
<Separator orientation="VERTICAL"/>
<Label text="TCP" styleClass="badge, badge-tcp"/>
<Label fx:id="labelServer" text="N/A"/>
</children>
</HBox>
<HBox alignment="CENTER_LEFT" HBox.hgrow="NEVER" minWidth="6.0">
<children>
<Separator orientation="VERTICAL"/>
</children>
</HBox>
<HBox alignment="CENTER_RIGHT" HBox.hgrow="SOMETIMES" minWidth="195.0">
<children>
<Label fx:id="bitcoinWallet" text="N/A" textAlignment="RIGHT" textOverrun="CLIP"/>
<Label fx:id="bitcoinChain" styleClass="chain" text="(N/A)" textOverrun="CLIP"/>
</children>
</HBox>
</children>
<HBox alignment="CENTER_LEFT" HBox.hgrow="ALWAYS" onContextMenuRequested="#openNodeIdContext">
<ImageView fitHeight="16.0" fitWidth="27.0" opacity="0.52" pickOnBounds="true"
preserveRatio="true">
<Image url="@../commons/images/eclair-shape.png" />
</ImageView>
<Label fx:id="labelNodeId" text="N/A" />
</HBox>
<HBox alignment="CENTER_LEFT" HBox.hgrow="SOMETIMES" minWidth="160.0">
<Separator orientation="VERTICAL" />
<Label text="TOTAL" styleClass="badge" />
<Label fx:id="statusBalanceLabel" styleClass="value" text="N/A" />
</HBox>
<HBox alignment="CENTER_LEFT" HBox.hgrow="SOMETIMES" minWidth="85.0">
<Separator orientation="VERTICAL" />
<Rectangle fx:id="rectRGB" width="7" height="7" fill="transparent" />
<Label fx:id="labelAlias" text="N/A" />
</HBox>
<HBox alignment="CENTER_LEFT" HBox.hgrow="NEVER" minWidth="85.0">
<Separator orientation="VERTICAL" />
<Label text="HTTP" styleClass="badge, badge-http" />
<Label fx:id="labelApi" styleClass="value" text="N/A" />
</HBox>
<HBox alignment="CENTER_LEFT" HBox.hgrow="NEVER" minWidth="80.0">
<Separator orientation="VERTICAL" />
<Label text="TCP" styleClass="badge, badge-tcp" />
<Label fx:id="labelServer" text="N/A" />
</HBox>
<HBox alignment="CENTER_LEFT" HBox.hgrow="NEVER" minWidth="6.0">
<Separator orientation="VERTICAL" />
</HBox>
<HBox alignment="CENTER_RIGHT" HBox.hgrow="SOMETIMES" minWidth="155.0">
<Label fx:id="bitcoinWallet" text="N/A" textAlignment="RIGHT" textOverrun="CLIP" />
<Label fx:id="bitcoinChain" styleClass="chain" text="(N/A)" textOverrun="CLIP" />
</HBox>
</HBox>
</bottom>
<top>

View file

@ -89,10 +89,11 @@ class FxApp extends Application with Logging {
val unitConf = setup.config.getString("gui.unit")
FxApp.unit = Try(CoinUtils.getUnitFromString(unitConf)) match {
case Failure(_) =>
logger.warn(s"$unitConf is not a valid gui unit, must be msat, sat, mbtc or btc. Defaulting to btc.")
logger.warn(s"$unitConf is not a valid gui unit, must be msat, sat, bits, mbtc or btc. Defaulting to btc.")
BtcUnit
case Success(u) => u
}
CoinUtils.setCoinPattern(CoinUtils.getPatternFromUnit(FxApp.unit))
val guiUpdater = system.actorOf(SimpleSupervisor.props(Props(classOf[GUIUpdater], controller), "gui-updater", SupervisorStrategy.Resume))
system.eventStream.subscribe(guiUpdater, classOf[ChannelEvent])
@ -108,8 +109,8 @@ class FxApp extends Application with Logging {
override def run(): Unit = {
val scene = new Scene(mainRoot)
primaryStage.setTitle("Eclair")
primaryStage.setMinWidth(600)
primaryStage.setWidth(960)
primaryStage.setMinWidth(750)
primaryStage.setWidth(980)
primaryStage.setMinHeight(400)
primaryStage.setHeight(640)
primaryStage.setOnCloseRequest(new EventHandler[WindowEvent] {

View file

@ -18,12 +18,6 @@ package fr.acinq.eclair.gui
import java.time.LocalDateTime
import java.util.function.Predicate
import javafx.application.Platform
import javafx.event.{ActionEvent, EventHandler}
import javafx.fxml.FXMLLoader
import javafx.scene.control.Alert.AlertType
import javafx.scene.control.{Alert, ButtonType}
import javafx.scene.layout.VBox
import akka.actor.{Actor, ActorLogging, ActorRef, Terminated}
import fr.acinq.bitcoin.Crypto.PublicKey
@ -31,12 +25,18 @@ import fr.acinq.bitcoin._
import fr.acinq.eclair.CoinUtils
import fr.acinq.eclair.blockchain.bitcoind.zmq.ZMQActor.{ZMQConnected, ZMQDisconnected}
import fr.acinq.eclair.blockchain.electrum.ElectrumClient.{ElectrumDisconnected, ElectrumReady}
import fr.acinq.eclair.channel._
import fr.acinq.eclair.channel.{Data, _}
import fr.acinq.eclair.gui.controllers._
import fr.acinq.eclair.payment.PaymentLifecycle.{LocalFailure, PaymentFailed, PaymentSucceeded, RemoteFailure}
import fr.acinq.eclair.payment._
import fr.acinq.eclair.router.{NORMAL => _, _}
import fr.acinq.eclair.wire.NodeAnnouncement
import javafx.application.Platform
import javafx.event.{ActionEvent, EventHandler}
import javafx.fxml.FXMLLoader
import javafx.scene.control.Alert.AlertType
import javafx.scene.control.{Alert, ButtonType}
import javafx.scene.layout.VBox
import scala.collection.JavaConversions._
@ -60,43 +60,23 @@ class GUIUpdater(mainController: MainController) extends Actor with ActorLogging
def receive: Receive = main(Map())
def createChannelPanel(channel: ActorRef, peer: ActorRef, remoteNodeId: PublicKey, isFunder: Boolean, temporaryChannelId: BinaryData): (ChannelPaneController, VBox) = {
def createChannelPanel(channel: ActorRef, peer: ActorRef, remoteNodeId: PublicKey, isFunder: Boolean, channelId: BinaryData): (ChannelPaneController, VBox) = {
log.info(s"new channel: $channel")
val loader = new FXMLLoader(getClass.getResource("/gui/main/channelPane.fxml"))
val channelPaneController = new ChannelPaneController(s"$remoteNodeId")
val channelPaneController = new ChannelPaneController(channel, remoteNodeId.toString())
loader.setController(channelPaneController)
val root = loader.load[VBox]
channelPaneController.nodeId.setText(remoteNodeId.toString())
channelPaneController.channelId.setText(temporaryChannelId.toString())
channelPaneController.channelId.setText(channelId.toString())
channelPaneController.funder.setText(if (isFunder) "Yes" else "No")
channelPaneController.close.setOnAction(new EventHandler[ActionEvent] {
override def handle(event: ActionEvent) = channel ! CMD_CLOSE(scriptPubKey = None)
})
channelPaneController.forceclose.setOnAction(new EventHandler[ActionEvent] {
override def handle(event: ActionEvent) = {
val alert = new Alert(AlertType.WARNING, "Careful: force-close is more expensive than a regular close and will incur a delay before funds are spendable.\n\nAre you sure you want to proceed?", ButtonType.YES, ButtonType.NO)
alert.showAndWait
if (alert.getResult eq ButtonType.YES) {
channel ! CMD_FORCECLOSE
}
}
})
// set the node alias if the node has already been announced
mainController.networkNodesList
.find(na => na.nodeId.toString.equals(remoteNodeId.toString))
.map(na => channelPaneController.updateRemoteNodeAlias(na.alias))
.foreach(na => channelPaneController.updateRemoteNodeAlias(na.alias))
(channelPaneController, root)
}
def updateBalance(channelPaneController: ChannelPaneController, commitments: Commitments) = {
val spec = commitments.localCommit.spec
channelPaneController.capacity.setText(CoinUtils.formatAmountInUnit(MilliSatoshi(spec.totalFunds), FxApp.getUnit, withUnit = true))
channelPaneController.amountUs.setText(CoinUtils.formatAmountInUnit(MilliSatoshi(spec.toLocalMsat), FxApp.getUnit, withUnit = true))
channelPaneController.balanceBar.setProgress(spec.toLocalMsat.toDouble / spec.totalFunds)
}
def main(m: Map[ActorRef, ChannelPaneController]): Receive = {
case ChannelCreated(channel, peer, remoteNodeId, isFunder, temporaryChannelId) =>
@ -108,29 +88,32 @@ class GUIUpdater(mainController: MainController) extends Actor with ActorLogging
case ChannelRestored(channel, peer, remoteNodeId, isFunder, channelId, currentData) =>
context.watch(channel)
val (channelPaneController, root) = createChannelPanel(channel, peer, remoteNodeId, isFunder, channelId)
currentData match {
case d: HasCommitments =>
updateBalance(channelPaneController, d.commitments)
channelPaneController.txId.setText(d.commitments.commitInput.outPoint.txid.toString())
case _ => {}
}
runInGuiThread(() => mainController.channelBox.getChildren.addAll(root))
context.become(main(m + (channel -> channelPaneController)))
channelPaneController.updateBalance(currentData.commitments)
val m1 = m + (channel -> channelPaneController)
val totalBalance = MilliSatoshi(m1.values.map(_.getBalance.amount).sum)
runInGuiThread(() => {
channelPaneController.refreshBalance()
mainController.refreshTotalBalance(totalBalance)
channelPaneController.txId.setText(currentData.commitments.commitInput.outPoint.txid.toString())
mainController.channelBox.getChildren.addAll(root)
})
context.become(main(m1))
case ShortChannelIdAssigned(channel, channelId, shortChannelId) if m.contains(channel) =>
val channelPaneController = m(channel)
runInGuiThread(() => channelPaneController.shortChannelId.setText(shortChannelId.toString))
case ChannelIdAssigned(channel, _, _, channelId) if m.contains(channel) =>
val channelPaneController = m(channel)
runInGuiThread(() => channelPaneController.channelId.setText(s"$channelId"))
runInGuiThread(() => channelPaneController.channelId.setText(channelId.toString()))
case ChannelStateChanged(channel, _, _, _, currentState, currentData) if m.contains(channel) =>
case ChannelStateChanged(channel, _, remoteNodeId, _, currentState, currentData) if m.contains(channel) =>
val channelPaneController = m(channel)
runInGuiThread { () =>
(currentState, currentData) match {
case (WAIT_FOR_FUNDING_CONFIRMED, d: HasCommitments) =>
channelPaneController.txId.setText(d.commitments.commitInput.outPoint.txid.toString())
case (WAIT_FOR_FUNDING_CONFIRMED, d: HasCommitments) => channelPaneController.txId.setText(d.commitments.commitInput.outPoint.txid.toString())
case _ => {}
}
channelPaneController.close.setVisible(STATE_MUTUAL_CLOSE.contains(currentState))
channelPaneController.forceclose.setVisible(STATE_FORCE_CLOSE.contains(currentState))
channelPaneController.state.setText(currentState.toString)
@ -138,19 +121,30 @@ class GUIUpdater(mainController: MainController) extends Actor with ActorLogging
case ChannelSignatureReceived(channel, commitments) if m.contains(channel) =>
val channelPaneController = m(channel)
runInGuiThread(() => updateBalance(channelPaneController, commitments))
channelPaneController.updateBalance(commitments)
val totalBalance = MilliSatoshi(m.values.map(_.getBalance.amount).sum)
runInGuiThread(() => {
channelPaneController.refreshBalance()
mainController.refreshTotalBalance(totalBalance)
})
case Terminated(actor) if m.contains(actor) =>
val channelPaneController = m(actor)
log.debug(s"channel=${channelPaneController.channelId.getText} to be removed from gui")
runInGuiThread(() => mainController.channelBox.getChildren.remove(channelPaneController.root))
val m1 = m - actor
val totalBalance = MilliSatoshi(m1.values.map(_.getBalance.amount).sum)
runInGuiThread(() => {
mainController.refreshTotalBalance(totalBalance)
})
context.become(main(m1))
case NodeDiscovered(nodeAnnouncement) =>
log.debug(s"peer node discovered with node id=${nodeAnnouncement.nodeId}")
runInGuiThread { () =>
if (!mainController.networkNodesList.exists(na => na.nodeId == nodeAnnouncement.nodeId)) {
mainController.networkNodesList.add(nodeAnnouncement)
m.foreach(f => if (nodeAnnouncement.nodeId.toString.equals(f._2.theirNodeIdValue)) {
m.foreach(f => if (nodeAnnouncement.nodeId.toString.equals(f._2.peerNodeId)) {
f._2.updateRemoteNodeAlias(nodeAnnouncement.alias)
})
}
@ -170,7 +164,7 @@ class GUIUpdater(mainController: MainController) extends Actor with ActorLogging
val idx = mainController.networkNodesList.indexWhere(na => na.nodeId == nodeAnnouncement.nodeId)
if (idx >= 0) {
mainController.networkNodesList.update(idx, nodeAnnouncement)
m.foreach(f => if (nodeAnnouncement.nodeId.toString.equals(f._2.theirNodeIdValue)) {
m.foreach(f => if (nodeAnnouncement.nodeId.toString.equals(f._2.peerNodeId)) {
f._2.updateRemoteNodeAlias(nodeAnnouncement.alias)
})
}

View file

@ -16,48 +16,59 @@
package fr.acinq.eclair.gui.controllers
import akka.actor.ActorRef
import com.google.common.base.Strings
import fr.acinq.bitcoin.MilliSatoshi
import fr.acinq.eclair.CoinUtils
import fr.acinq.eclair.channel.{CMD_CLOSE, CMD_FORCECLOSE, Commitments}
import fr.acinq.eclair.gui.FxApp
import javafx.application.Platform
import javafx.beans.value.{ChangeListener, ObservableValue}
import javafx.fxml.FXML
import javafx.scene.control._
import javafx.scene.input.{ContextMenuEvent, MouseEvent}
import javafx.scene.layout.VBox
import fr.acinq.eclair.gui.utils.{ContextMenuUtils, CopyAction}
import grizzled.slf4j.Logging
import javafx.event.{ActionEvent, EventHandler}
import javafx.scene.control.Alert.AlertType
/**
* Created by DPA on 23/09/2016.
*/
class ChannelPaneController(val theirNodeIdValue: String) extends Logging {
class ChannelPaneController(val channelRef: ActorRef, val peerNodeId: String) extends Logging {
@FXML var root: VBox = _
@FXML var channelId: TextField = _
@FXML var shortChannelId: TextField = _
@FXML var txId: TextField = _
@FXML var balanceBar: ProgressBar = _
@FXML var amountUs: TextField = _
@FXML var amountUs: Label = _
@FXML var nodeAlias: Label = _
@FXML var nodeId: TextField = _
@FXML var capacity: TextField = _
@FXML var funder: TextField = _
@FXML var state: TextField = _
@FXML var funder: TextField = _
@FXML var close: Button = _
@FXML var forceclose: Button = _
var contextMenu: ContextMenu = _
private var contextMenu: ContextMenu = _
private var balance: MilliSatoshi = MilliSatoshi(0)
private var capacity: MilliSatoshi = MilliSatoshi(0)
private def buildChannelContextMenu() = {
private def buildChannelContextMenu(): Unit = {
Platform.runLater(new Runnable() {
override def run() = {
contextMenu = ContextMenuUtils.buildCopyContext(List(
CopyAction("Copy Channel Id", channelId.getText),
CopyAction("Copy Peer Pubkey", theirNodeIdValue),
CopyAction("Copy Tx Id", txId.getText())
CopyAction("Copy channel id", channelId.getText),
CopyAction("Copy peer pubkey", peerNodeId),
CopyAction("Copy tx id", txId.getText())
))
contextMenu.getStyleClass.add("context-channel")
channelId.setContextMenu(contextMenu)
shortChannelId.setContextMenu(contextMenu)
txId.setContextMenu(contextMenu)
amountUs.setContextMenu(contextMenu)
nodeId.setContextMenu(contextMenu)
capacity.setContextMenu(contextMenu)
funder.setContextMenu(contextMenu)
state.setContextMenu(contextMenu)
}
@ -68,6 +79,38 @@ class ChannelPaneController(val theirNodeIdValue: String) extends Logging {
channelId.textProperty.addListener(new ChangeListener[String] {
override def changed(observable: ObservableValue[_ <: String], oldValue: String, newValue: String) = buildChannelContextMenu()
})
nodeId.setText(peerNodeId)
nodeAlias.managedProperty.bind(nodeAlias.visibleProperty)
close.setOnAction(new EventHandler[ActionEvent] {
override def handle(event: ActionEvent) = {
val alert = new Alert(AlertType.CONFIRMATION,
s"""
|Are you sure you want to close this channel?
|
|$getChannelDetails
|""".stripMargin, ButtonType.YES, ButtonType.NO)
alert.showAndWait
if (alert.getResult eq ButtonType.YES) {
channelRef ! CMD_CLOSE(scriptPubKey = None)
}
}
})
forceclose.setOnAction(new EventHandler[ActionEvent] {
override def handle(event: ActionEvent) = {
val alert = new Alert(AlertType.WARNING,
s"""
|Careful: force-close is more expensive than a regular close and will incur a delay before funds are spendable.
|
|Are you sure you want to forcibly close this channel?
|
|$getChannelDetails
""".stripMargin, ButtonType.YES, ButtonType.NO)
alert.showAndWait
if (alert.getResult eq ButtonType.YES) {
channelRef ! CMD_FORCECLOSE
}
}
})
buildChannelContextMenu()
}
@ -81,6 +124,31 @@ class ChannelPaneController(val theirNodeIdValue: String) extends Logging {
}
def updateRemoteNodeAlias(alias: String) {
Option(nodeId).map((n: TextField) => n.setText(s"$theirNodeIdValue ($alias)"))
nodeAlias.setText(alias)
nodeAlias.setVisible(!Strings.isNullOrEmpty(alias))
}
def updateBalance(commitments: Commitments) {
balance = MilliSatoshi(commitments.localCommit.spec.toLocalMsat)
capacity = MilliSatoshi(commitments.localCommit.spec.totalFunds)
}
def refreshBalance(): Unit = {
amountUs.setText(s"${CoinUtils.formatAmountInUnit(balance, FxApp.getUnit)} / ${CoinUtils.formatAmountInUnit(capacity, FxApp.getUnit, withUnit = true)}")
balanceBar.setProgress(balance.amount.toDouble / capacity.amount)
}
def getBalance: MilliSatoshi = balance
def getCapacity: MilliSatoshi = capacity
def getChannelDetails: String =
s"""
|Channel details:
|---
|Id: ${channelId.getText().substring(0, 18)}...
|Peer: ${peerNodeId.toString().substring(0, 18)}...
|Balance: ${CoinUtils.formatAmountInUnit(getBalance, FxApp.getUnit, withUnit = true)}
|State: ${state.getText}
|"""
}

View file

@ -20,6 +20,17 @@ import java.text.NumberFormat
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import com.google.common.net.HostAndPort
import fr.acinq.bitcoin.{MilliSatoshi, Satoshi}
import fr.acinq.eclair.NodeParams.{BITCOIND, ELECTRUM}
import fr.acinq.eclair.gui.stages._
import fr.acinq.eclair.gui.utils.{ContextMenuUtils, CopyAction}
import fr.acinq.eclair.gui.{FxApp, Handlers}
import fr.acinq.eclair.payment.{PaymentEvent, PaymentReceived, PaymentRelayed, PaymentSent}
import fr.acinq.eclair.wire.{ChannelAnnouncement, NodeAnnouncement}
import fr.acinq.eclair.{CoinUtils, Setup}
import grizzled.slf4j.Logging
import javafx.animation.{FadeTransition, ParallelTransition, SequentialTransition, TranslateTransition}
import javafx.application.{HostServices, Platform}
import javafx.beans.property._
@ -36,21 +47,9 @@ import javafx.scene.layout.{AnchorPane, HBox, StackPane, VBox}
import javafx.scene.paint.Color
import javafx.scene.shape.Rectangle
import javafx.scene.text.Text
import javafx.stage.FileChooser.ExtensionFilter
import javafx.stage._
import javafx.util.{Callback, Duration}
import com.google.common.net.HostAndPort
import fr.acinq.bitcoin.{MilliSatoshi, Satoshi}
import fr.acinq.eclair.NodeParams.{BITCOIND, ELECTRUM}
import fr.acinq.eclair.gui.stages._
import fr.acinq.eclair.gui.utils.{ContextMenuUtils, CopyAction}
import fr.acinq.eclair.gui.{FxApp, Handlers}
import fr.acinq.eclair.payment.{PaymentEvent, PaymentReceived, PaymentRelayed, PaymentSent}
import fr.acinq.eclair.wire.{ChannelAnnouncement, NodeAnnouncement}
import fr.acinq.eclair.{CoinUtils, Setup}
import grizzled.slf4j.Logging
case class ChannelInfo(announcement: ChannelAnnouncement,
var feeBaseMsatNode1_opt: Option[Long], var feeBaseMsatNode2_opt: Option[Long],
var feeProportionalMillionthsNode1_opt: Option[Long], var feeProportionalMillionthsNode2_opt: Option[Long],
@ -82,6 +81,7 @@ class MainController(val handlers: Handlers, val hostServices: HostServices) ext
// status bar elements
@FXML var labelNodeId: Label = _
@FXML var statusBalanceLabel: Label = _
@FXML var rectRGB: Rectangle = _
@FXML var labelAlias: Label = _
@FXML var labelApi: Label = _
@ -240,16 +240,15 @@ class MainController(val handlers: Handlers, val hostServices: HostServices) ext
networkChannelsFeeBaseMsatNode2Column.setCellValueFactory(new Callback[CellDataFeatures[ChannelInfo, String], ObservableValue[String]]() {
def call(pc: CellDataFeatures[ChannelInfo, String]) = new SimpleStringProperty(
pc.getValue.feeBaseMsatNode2_opt.map(f => CoinUtils.formatAmountInUnit(MilliSatoshi(f), FxApp.getUnit, withUnit = true)).getOrElse("?"))
// CoinUtils.formatAmountInUnit(MilliSatoshi(pc.getValue.feeBaseMsatNode2), FxApp.getUnit, withUnit = true))
})
// feeProportionalMillionths is fee per satoshi in millionths of a satoshi
networkChannelsFeeProportionalMillionthsNode1Column.setCellValueFactory(new Callback[CellDataFeatures[ChannelInfo, String], ObservableValue[String]]() {
def call(pc: CellDataFeatures[ChannelInfo, String]) = new SimpleStringProperty(
pc.getValue.feeProportionalMillionthsNode1_opt.map(f => s"${CoinUtils.COIN_FORMAT.format(f.toDouble / 1000000 * 100)}%").getOrElse("?"))
pc.getValue.feeProportionalMillionthsNode1_opt.map(f => s"${NumberFormat.getInstance().format(f.toDouble / 1000000 * 100)}%").getOrElse("?"))
})
networkChannelsFeeProportionalMillionthsNode2Column.setCellValueFactory(new Callback[CellDataFeatures[ChannelInfo, String], ObservableValue[String]]() {
def call(pc: CellDataFeatures[ChannelInfo, String]) = new SimpleStringProperty(
pc.getValue.feeProportionalMillionthsNode2_opt.map(f => s"${CoinUtils.COIN_FORMAT.format(f.toDouble / 1000000 * 100)}%").getOrElse("?"))
pc.getValue.feeProportionalMillionthsNode2_opt.map(f => s"${NumberFormat.getInstance().format(f.toDouble / 1000000 * 100)}%").getOrElse("?"))
})
networkChannelsCapacityColumn.setCellValueFactory(new Callback[CellDataFeatures[ChannelInfo, String], ObservableValue[String]]() {
def call(pc: CellDataFeatures[ChannelInfo, String]) = new SimpleStringProperty(
@ -572,4 +571,8 @@ class MainController(val handlers: Handlers, val hostServices: HostServices) ext
childStage.setX(getWindow.map(w => w.getX + w.getWidth / 2 - childStage.getWidth / 2).getOrElse(0))
childStage.setY(getWindow.map(w => w.getY + w.getHeight / 2 - childStage.getHeight / 2).getOrElse(0))
}
def refreshTotalBalance(total: MilliSatoshi): Unit = {
statusBalanceLabel.setText(CoinUtils.formatAmountInUnit(total, FxApp.getUnit, withUnit = true))
}
}

View file

@ -17,10 +17,9 @@
package fr.acinq.eclair.gui.utils
import javafx.collections.FXCollections
import fr.acinq.eclair.{BtcUnit, MBtcUnit, MSatUnit, SatUnit}
import fr.acinq.eclair._
object Constants {
val FX_UNITS_ARRAY_NO_MSAT = FXCollections.observableArrayList(SatUnit.label, MBtcUnit.label, BtcUnit.label)
val FX_UNITS_ARRAY = FXCollections.observableArrayList(MSatUnit.label, SatUnit.label, MBtcUnit.label, BtcUnit.label)
val FX_UNITS_ARRAY_NO_MSAT = FXCollections.observableArrayList(SatUnit.label, BitUnit.label, MBtcUnit.label, BtcUnit.label)
val FX_UNITS_ARRAY = FXCollections.observableArrayList(MSatUnit.label, SatUnit.label, BitUnit.label, MBtcUnit.label, BtcUnit.label)
}