1
0
Fork 0
mirror of https://github.com/ACINQ/eclair.git synced 2025-03-13 19:37:35 +01:00

Merge branch 'master' into android

This commit is contained in:
sstone 2019-11-28 10:07:29 +01:00
commit e78e091e62
No known key found for this signature in database
GPG key ID: 7A73FE77DE2C4027
18 changed files with 720 additions and 605 deletions

View file

@ -34,7 +34,7 @@ import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentRequest
import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, OutgoingChannels, UsableBalance}
import fr.acinq.eclair.payment._
import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceivePayment
import fr.acinq.eclair.router.{Announcements, ChannelDesc, PublicChannel, RouteRequest, RouteResponse, Router}
import fr.acinq.eclair.router.{Announcements, ChannelDesc, GetNetworkStats, NetworkStats, PublicChannel, RouteRequest, RouteResponse, Router}
import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement}
import scodec.bits.ByteVector
@ -100,6 +100,8 @@ trait Eclair {
def channelStats()(implicit timeout: Timeout): Future[Seq[Stats]]
def networkStats()(implicit timeout: Timeout): Future[Option[NetworkStats]]
def getInvoice(paymentHash: ByteVector32)(implicit timeout: Timeout): Future[Option[PaymentRequest]]
def pendingInvoices(from_opt: Option[Long], to_opt: Option[Long])(implicit timeout: Timeout): Future[Seq[PaymentRequest]]
@ -271,6 +273,8 @@ class EclairImpl(appKit: Kit) extends Eclair {
override def channelStats()(implicit timeout: Timeout): Future[Seq[Stats]] = Future(appKit.nodeParams.db.audit.stats)
override def networkStats()(implicit timeout: Timeout): Future[Option[NetworkStats]] = (appKit.router ? GetNetworkStats).mapTo[Option[NetworkStats]]
override def allInvoices(from_opt: Option[Long], to_opt: Option[Long])(implicit timeout: Timeout): Future[Seq[PaymentRequest]] = Future {
val filter = getDefaultTimestampFilters(from_opt, to_opt)

View file

@ -16,8 +16,10 @@
package fr.acinq.eclair
import fr.acinq.eclair.payment.{LocalFailure, PaymentFailure, RemoteFailure, UnreadableRemoteFailure}
import kamon.Kamon
import kamon.tag.TagSet
import kamon.trace.Span
import scala.concurrent.{ExecutionContext, Future}
@ -39,4 +41,15 @@ object KamonExt {
res
}
/**
* A helper function that fails a span with proper messages when dealing with payments
*/
def failSpan(span: Span, failure: PaymentFailure) = {
failure match {
case LocalFailure(t) => span.fail("local failure", t)
case RemoteFailure(_, e) => span.fail(s"remote failure: origin=${e.originNode} error=${e.failureMessage}")
case UnreadableRemoteFailure(_) => span.fail("unreadable remote failure")
}
}
}

View file

@ -26,13 +26,15 @@ import fr.acinq.eclair.channel.Commitments
import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.payment.PaymentRequest.ExtraHop
import fr.acinq.eclair.payment.PaymentSent.PartialPayment
import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, OutgoingChannel, OutgoingChannels}
import fr.acinq.eclair.payment._
import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, OutgoingChannel, OutgoingChannels}
import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentConfig
import fr.acinq.eclair.payment.send.PaymentLifecycle.SendPayment
import fr.acinq.eclair.router._
import fr.acinq.eclair.wire.{Onion, OnionRoutingPacket, OnionTlv, PaymentTimeout, UpdateAddHtlc}
import fr.acinq.eclair.{CltvExpiry, FSMDiagnosticActorLogging, Logs, LongToBtcAmount, MilliSatoshi, NodeParams, ToMilliSatoshiConversion}
import fr.acinq.eclair.wire._
import fr.acinq.eclair.{CltvExpiry, FSMDiagnosticActorLogging, Logs, LongToBtcAmount, MilliSatoshi, NodeParams, ShortChannelId, ToMilliSatoshiConversion}
import kamon.Kamon
import kamon.context.Context
import scodec.bits.ByteVector
import scala.annotation.tailrec
@ -54,6 +56,12 @@ class MultiPartPaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig,
val id = cfg.id
private val span = Kamon.spanBuilder("multi-part-payment")
.tag("parentPaymentId", cfg.parentId.toString)
.tag("paymentHash", cfg.paymentHash.toHex)
.tag("targetNodeId", cfg.targetNodeId.toString())
.start()
startWith(WAIT_FOR_PAYMENT_REQUEST, WaitingForRequest)
when(WAIT_FOR_PAYMENT_REQUEST) {
@ -72,35 +80,25 @@ class MultiPartPaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig,
router ! TickComputeNetworkStats
}
relayer ! GetOutgoingChannels()
goto(WAIT_FOR_CHANNEL_BALANCES) using PaymentProgress(d.sender, d.request, s.stats, d.request.totalAmount, d.request.maxAttempts, Map.empty, Nil)
goto(WAIT_FOR_CHANNEL_BALANCES) using WaitingForChannelBalances(d.sender, d.request, s.stats)
}
when(WAIT_FOR_CHANNEL_BALANCES) {
case Event(OutgoingChannels(channels), d: PaymentProgress) =>
log.debug("trying to send {} with local channels: {}", d.toSend, channels.map(_.toUsableBalance).mkString(","))
val randomize = d.failures.nonEmpty // we randomize channel selection when we retry
val (remaining, payments) = splitPayment(nodeParams, d.toSend, channels, d.networkStats, d.request, randomize)
case Event(OutgoingChannels(channels), d: WaitingForChannelBalances) =>
log.debug("trying to send {} with local channels: {}", d.request.totalAmount, channels.map(_.toUsableBalance).mkString(","))
val (remaining, payments) = splitPayment(nodeParams, d.request.totalAmount, channels, d.networkStats, d.request, randomize = false)
if (remaining > 0.msat) {
log.warning(s"cannot send ${d.toSend} with our current balance")
goto(PAYMENT_ABORTED) using PaymentAborted(d.sender, d.request, d.failures :+ LocalFailure(new RuntimeException("balance is too low")), d.pending.keySet)
log.warning(s"cannot send ${d.request.totalAmount} with our current balance")
goto(PAYMENT_ABORTED) using PaymentAborted(d.sender, d.request, LocalFailure(BalanceTooLow) :: Nil, Set.empty)
} else {
val pending = setFees(d.request.routeParams, payments, payments.size + d.pending.size)
pending.foreach { case (childId, payment) => spawnChildPaymentFsm(childId) ! payment }
goto(PAYMENT_IN_PROGRESS) using d.copy(toSend = 0 msat, remainingAttempts = d.remainingAttempts - 1, pending = d.pending ++ pending)
val pending = setFees(d.request.routeParams, payments, payments.size)
Kamon.runWithContextEntry(parentPaymentIdKey, cfg.parentId) {
Kamon.runWithSpan(span, finishSpan = true) {
pending.foreach { case (childId, payment) => spawnChildPaymentFsm(childId) ! payment }
}
}
goto(PAYMENT_IN_PROGRESS) using PaymentProgress(d.sender, d.request, d.networkStats, channels.length, 0 msat, d.request.maxAttempts - 1, pending, Set.empty, Nil)
}
case Event(pf: PaymentFailed, d: PaymentProgress) => handleChildFailure(pf, d) match {
case Some(paymentAborted) =>
goto(PAYMENT_ABORTED) using paymentAborted
case None =>
val failedPayment = d.pending(pf.id)
stay using d.copy(toSend = d.toSend + failedPayment.finalPayload.amount, pending = d.pending - pf.id, failures = d.failures ++ pf.failures)
}
case Event(ps: PaymentSent, d: PaymentProgress) =>
require(ps.parts.length == 1, "child payment must contain only one part")
// As soon as we get the preimage we can consider that the whole payment succeeded (we have a proof of payment).
goto(PAYMENT_SUCCEEDED) using PaymentSucceeded(d.sender, d.request, ps.paymentPreimage, ps.parts, d.pending.keySet - ps.id)
}
when(PAYMENT_IN_PROGRESS) {
@ -111,7 +109,58 @@ class MultiPartPaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig,
// Get updated local channels (will take into account the child payments that are in-flight).
relayer ! GetOutgoingChannels()
val failedPayment = d.pending(pf.id)
goto(WAIT_FOR_CHANNEL_BALANCES) using d.copy(toSend = d.toSend + failedPayment.finalPayload.amount, pending = d.pending - pf.id, failures = d.failures ++ pf.failures)
val shouldBlacklist = shouldBlacklistChannel(pf)
if (shouldBlacklist) {
log.debug(s"ignoring channel ${getFirstHopShortChannelId(failedPayment)} to ${failedPayment.routePrefix.head.nextNodeId}")
}
val ignoreChannels = if (shouldBlacklist) d.ignoreChannels + getFirstHopShortChannelId(failedPayment) else d.ignoreChannels
val remainingAttempts = if (shouldBlacklist && Random.nextDouble() * math.log(d.channelsCount) > 2.0) {
// When we have a lot of channels, many of them may end up being a bad route prefix for the destination we're
// trying to reach. This is a cheap error that is detected quickly (RouteNotFound), so we don't want to count
// it in our payment attempts to avoid failing too fast.
// However we don't want to test all of our channels either which would be expensive, so we only probabilistically
// count the failure in our payment attempts.
// With the log-scale used, here are the probabilities and the corresponding number of retries:
// * 10 channels -> refund 13% of failures -> with 5 initial retries we will actually try 5/(1-0.13) = ~6 times
// * 20 channels -> refund 32% of failures -> with 5 initial retries we will actually try 5/(1-0.32) = ~7 times
// * 50 channels -> refund 50% of failures -> with 5 initial retries we will actually try 5/(1-0.50) = ~10 times
// * 100 channels -> refund 56% of failures -> with 5 initial retries we will actually try 5/(1-0.56) = ~11 times
// * 1000 channels -> refund 70% of failures -> with 5 initial retries we will actually try 5/(1-0.70) = ~17 times
// NB: this hack won't be necessary once multi-part is directly handled by the router.
d.remainingAttempts + 1
} else {
d.remainingAttempts
}
goto(RETRY_WITH_UPDATED_BALANCES) using d.copy(toSend = d.toSend + failedPayment.finalPayload.amount, pending = d.pending - pf.id, failures = d.failures ++ pf.failures, ignoreChannels = ignoreChannels, remainingAttempts = remainingAttempts)
}
case Event(ps: PaymentSent, d: PaymentProgress) =>
require(ps.parts.length == 1, "child payment must contain only one part")
// As soon as we get the preimage we can consider that the whole payment succeeded (we have a proof of payment).
goto(PAYMENT_SUCCEEDED) using PaymentSucceeded(d.sender, d.request, ps.paymentPreimage, ps.parts, d.pending.keySet - ps.id)
}
when(RETRY_WITH_UPDATED_BALANCES) {
case Event(OutgoingChannels(channels), d: PaymentProgress) =>
log.debug("trying to send {} with local channels: {}", d.toSend, channels.map(_.toUsableBalance).mkString(","))
val filteredChannels = channels.filter(c => !d.ignoreChannels.contains(c.channelUpdate.shortChannelId))
val (remaining, payments) = splitPayment(nodeParams, d.toSend, filteredChannels, d.networkStats, d.request, randomize = true) // we randomize channel selection when we retry
if (remaining > 0.msat) {
log.warning(s"cannot send ${d.toSend} with our current balance")
goto(PAYMENT_ABORTED) using PaymentAborted(d.sender, d.request, d.failures :+ LocalFailure(BalanceTooLow), d.pending.keySet)
} else {
val pending = setFees(d.request.routeParams, payments, payments.size + d.pending.size)
pending.foreach { case (childId, payment) => spawnChildPaymentFsm(childId) ! payment }
goto(PAYMENT_IN_PROGRESS) using d.copy(toSend = 0 msat, remainingAttempts = d.remainingAttempts - 1, pending = d.pending ++ pending, channelsCount = channels.length)
}
case Event(pf: PaymentFailed, d: PaymentProgress) => handleChildFailure(pf, d) match {
case Some(paymentAborted) =>
goto(PAYMENT_ABORTED) using paymentAborted
case None =>
val failedPayment = d.pending(pf.id)
val ignoreChannels = if (shouldBlacklistChannel(pf)) d.ignoreChannels + getFirstHopShortChannelId(failedPayment) else d.ignoreChannels
stay using d.copy(toSend = d.toSend + failedPayment.finalPayload.amount, pending = d.pending - pf.id, failures = d.failures ++ pf.failures, ignoreChannels = ignoreChannels)
}
case Event(ps: PaymentSent, d: PaymentProgress) =>
@ -125,9 +174,7 @@ class MultiPartPaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig,
val failures = d.failures ++ pf.failures
val pending = d.pending - pf.id
if (pending.isEmpty) {
log.warning("multi-part payment failed")
reply(d.sender, PaymentFailed(id, d.request.paymentHash, failures))
stop(FSM.Normal)
myStop(d.sender, Left(PaymentFailed(id, d.request.paymentHash, failures)))
} else {
stay using d.copy(failures = failures, pending = pending)
}
@ -146,9 +193,7 @@ class MultiPartPaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig,
val parts = d.parts ++ ps.parts
val pending = d.pending - ps.id
if (pending.isEmpty) {
log.info("multi-part payment succeeded")
reply(d.sender, PaymentSent(id, d.request.paymentHash, d.preimage, parts))
stop(FSM.Normal)
myStop(d.sender, Right(PaymentSent(id, d.request.paymentHash, d.preimage, parts)))
} else {
stay using d.copy(parts = parts, pending = pending)
}
@ -159,9 +204,7 @@ class MultiPartPaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig,
log.warning(s"payment succeeded but partial payment failed (id=${pf.id})")
val pending = d.pending - pf.id
if (pending.isEmpty) {
log.info("multi-part payment succeeded")
reply(d.sender, PaymentSent(id, d.request.paymentHash, d.preimage, d.parts))
stop(FSM.Normal)
myStop(d.sender, Right(PaymentSent(id, d.request.paymentHash, d.preimage, d.parts)))
} else {
stay using d.copy(pending = pending)
}
@ -170,17 +213,13 @@ class MultiPartPaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig,
onTransition {
case _ -> PAYMENT_ABORTED => nextStateData match {
case d: PaymentAborted if d.pending.isEmpty =>
log.warning("multi-part payment failed")
reply(d.sender, PaymentFailed(id, d.request.paymentHash, d.failures))
stop(FSM.Normal)
myStop(d.sender, Left(PaymentFailed(id, d.request.paymentHash, d.failures)))
case _ =>
}
case _ -> PAYMENT_SUCCEEDED => nextStateData match {
case d: PaymentSucceeded if d.pending.isEmpty =>
log.info("multi-part payment succeeded")
reply(d.sender, PaymentSent(id, d.request.paymentHash, d.preimage, d.parts))
stop(FSM.Normal)
myStop(d.sender, Right(PaymentSent(id, d.request.paymentHash, d.preimage, d.parts)))
case _ =>
}
}
@ -190,6 +229,20 @@ class MultiPartPaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig,
context.actorOf(PaymentLifecycle.props(nodeParams, childCfg, router, register))
}
def myStop(origin: ActorRef, event: Either[PaymentFailed, PaymentSent]): State = {
event match {
case Left(paymentFailed) =>
log.warning("multi-part payment failed")
reply(origin, paymentFailed)
span.fail("payment failed")
case Right(paymentSent) =>
log.info("multi-part payment succeeded")
reply(origin, paymentSent)
}
span.finish()
stop(FSM.Normal)
}
def reply(to: ActorRef, e: PaymentEvent): Unit = {
to ! e
if (cfg.publishEvent) context.system.eventStream.publish(e)
@ -203,7 +256,7 @@ class MultiPartPaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig,
if (paymentTimedOut) {
Some(PaymentAborted(d.sender, d.request, d.failures ++ pf.failures, d.pending.keySet - pf.id))
} else if (d.remainingAttempts == 0) {
val failure = LocalFailure(new RuntimeException("payment attempts exhausted without success"))
val failure = LocalFailure(RetryExhausted)
Some(PaymentAborted(d.sender, d.request, d.failures ++ pf.failures :+ failure, d.pending.keySet - pf.id))
} else {
None
@ -220,6 +273,8 @@ class MultiPartPaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig,
object MultiPartPaymentLifecycle {
val parentPaymentIdKey = Context.key[UUID]("parentPaymentId", UUID.fromString("00000000-0000-0000-0000-000000000000"))
def props(nodeParams: NodeParams, cfg: SendPaymentConfig, relayer: ActorRef, router: ActorRef, register: ActorRef) = Props(new MultiPartPaymentLifecycle(nodeParams, cfg, relayer, router, register))
case class SendMultiPartPayment(paymentHash: ByteVector32,
@ -234,12 +289,18 @@ object MultiPartPaymentLifecycle {
require(totalAmount > 0.msat, s"total amount must be > 0")
}
// @formatter:off
object BalanceTooLow extends RuntimeException("outbound capacity is too low")
object RetryExhausted extends RuntimeException("payment attempts exhausted without success")
// @formatter:on
// @formatter:off
sealed trait State
case object WAIT_FOR_PAYMENT_REQUEST extends State
case object WAIT_FOR_NETWORK_STATS extends State
case object WAIT_FOR_CHANNEL_BALANCES extends State
case object WAIT_FOR_CHANNEL_BALANCES extends State
case object PAYMENT_IN_PROGRESS extends State
case object RETRY_WITH_UPDATED_BALANCES extends State
case object PAYMENT_ABORTED extends State
case object PAYMENT_SUCCEEDED extends State
@ -251,10 +312,18 @@ object MultiPartPaymentLifecycle {
/**
* During initialization, we collect network statistics to help us decide how to best split a big payment.
*
* @param sender the sender of the payment request.
* @param request payment request containing the total amount to send.
* @param sender the sender of the payment request.
* @param request payment request containing the total amount to send.
*/
case class WaitingForNetworkStats(sender: ActorRef, request: SendMultiPartPayment) extends Data
/**
* During initialization, we request our local channels balances.
*
* @param sender the sender of the payment request.
* @param request payment request containing the total amount to send.
* @param networkStats network statistics help us decide how to best split a big payment.
*/
case class WaitingForChannelBalances(sender: ActorRef, request: SendMultiPartPayment, networkStats: Option[NetworkStats]) extends Data
/**
* While the payment is in progress, we listen to child payment failures. When we receive such failures, we request
* our up-to-date local channels balances and retry the failed child payments with a potentially different route.
@ -262,12 +331,14 @@ object MultiPartPaymentLifecycle {
* @param sender the sender of the payment request.
* @param request payment request containing the total amount to send.
* @param networkStats network statistics help us decide how to best split a big payment.
* @param channelsCount number of local channels.
* @param toSend remaining amount that should be split and sent.
* @param remainingAttempts remaining attempts (after child payments fail).
* @param pending pending child payments (payment sent, we are waiting for a fulfill or a failure).
* @param ignoreChannels channels that should be ignored (previously returned a permanent error).
* @param failures previous child payment failures.
*/
case class PaymentProgress(sender: ActorRef, request: SendMultiPartPayment, networkStats: Option[NetworkStats], toSend: MilliSatoshi, remainingAttempts: Int, pending: Map[UUID, SendPayment], failures: Seq[PaymentFailure]) extends Data
case class PaymentProgress(sender: ActorRef, request: SendMultiPartPayment, networkStats: Option[NetworkStats], channelsCount: Int, toSend: MilliSatoshi, remainingAttempts: Int, pending: Map[UUID, SendPayment], ignoreChannels: Set[ShortChannelId], failures: Seq[PaymentFailure]) extends Data
/**
* When we exhaust our retry attempts without success, we abort the payment.
* Once we're in that state, we wait for all the pending child payments to settle.
@ -292,6 +363,17 @@ object MultiPartPaymentLifecycle {
case class PaymentSucceeded(sender: ActorRef, request: SendMultiPartPayment, preimage: ByteVector32, parts: Seq[PartialPayment], pending: Set[UUID]) extends Data
// @formatter:on
/** If the payment failed immediately with a RouteNotFound, the channel we selected should be ignored in retries. */
private def shouldBlacklistChannel(pf: PaymentFailed): Boolean = pf.failures match {
case LocalFailure(RouteNotFound) :: Nil => true
case _ => false
}
def getFirstHopShortChannelId(payment: SendPayment): ShortChannelId = {
require(payment.routePrefix.nonEmpty, "multi-part payment must have a route prefix")
payment.routePrefix.head.lastUpdate.shortChannelId
}
/**
* If fee limits are provided, we need to divide them between all child payments. Otherwise we could end up paying
* N * maxFee (where N is the number of child payments).
@ -415,14 +497,17 @@ object MultiPartPaymentLifecycle {
// If we have direct channels to the target, we use them without splitting the payment inside each channel.
val channelsToTarget = localChannels.filter(p => p.nextNodeId == request.targetNodeId).sortBy(_.commitments.availableBalanceForSend)
val directPayments = split(toSend, Seq.empty, channelsToTarget, (remaining: MilliSatoshi, channel: OutgoingChannel) => {
createChildPayment(nodeParams, request, remaining.min(channel.commitments.availableBalanceForSend), channel) :: Nil
// When using direct channels to the destination, it doesn't make sense to use retries so we set maxAttempts to 1.
createChildPayment(nodeParams, request.copy(maxAttempts = 1), remaining.min(channel.commitments.availableBalanceForSend), channel) :: Nil
})
// Otherwise we need to split the amount based on network statistics and pessimistic fees estimates.
// We filter out unannounced channels: they are very likely leading to a non-routing node.
// Note that this will be handled more gracefully once this logic is migrated inside the router.
val channels = if (randomize) {
Random.shuffle(localChannels.filter(p => p.nextNodeId != request.targetNodeId))
Random.shuffle(localChannels.filter(p => p.commitments.announceChannel && p.nextNodeId != request.targetNodeId))
} else {
localChannels.filter(p => p.nextNodeId != request.targetNodeId).sortBy(_.commitments.availableBalanceForSend)
localChannels.filter(p => p.commitments.announceChannel && p.nextNodeId != request.targetNodeId).sortBy(_.commitments.availableBalanceForSend)
}
val remotePayments = split(toSend - directPayments.map(_.finalPayload.amount).sum, Seq.empty, channels, (remaining: MilliSatoshi, channel: OutgoingChannel) => {
// We re-generate a split threshold for each channel to randomize the amounts.

View file

@ -42,27 +42,24 @@ class PaymentInitiator(nodeParams: NodeParams, router: ActorRef, relayer: ActorR
override def receive: Receive = {
case r: SendPaymentRequest =>
val paymentId = UUID.randomUUID()
sender ! paymentId
val paymentCfg = SendPaymentConfig(paymentId, paymentId, r.externalId, r.paymentHash, r.targetNodeId, r.paymentRequest, storeInDb = true, publishEvent = true)
val finalExpiry = r.finalExpiry(nodeParams.currentBlockHeight)
if (r.paymentRequest.exists(!_.features.supported)) {
sender ! paymentId
sender ! PaymentFailed(paymentId, r.paymentHash, LocalFailure(new IllegalArgumentException(s"can't send payment: unknown invoice features (${r.paymentRequest.get.features})")) :: Nil)
} else {
r.paymentRequest match {
case Some(invoice) if invoice.features.allowMultiPart =>
r.predefinedRoute match {
case Nil => spawnMultiPartPaymentFsm(paymentCfg) forward SendMultiPartPayment(r.paymentHash, invoice.paymentSecret.get, r.targetNodeId, r.amount, finalExpiry, r.maxAttempts, r.assistedRoutes, r.routeParams)
case hops => spawnPaymentFsm(paymentCfg) forward SendPaymentToRoute(r.paymentHash, hops, Onion.createMultiPartPayload(r.amount, invoice.amount.getOrElse(r.amount), finalExpiry, invoice.paymentSecret.get))
}
case _ =>
val payFsm = spawnPaymentFsm(paymentCfg)
// NB: we only generate legacy payment onions for now for maximum compatibility.
r.predefinedRoute match {
case Nil => payFsm forward SendPayment(r.paymentHash, r.targetNodeId, FinalLegacyPayload(r.amount, finalExpiry), r.maxAttempts, r.assistedRoutes, r.routeParams)
case hops => payFsm forward SendPaymentToRoute(r.paymentHash, hops, FinalLegacyPayload(r.amount, finalExpiry))
}
}
sender ! paymentId
r.paymentRequest match {
case Some(invoice) if !invoice.features.supported =>
sender ! PaymentFailed(paymentId, r.paymentHash, LocalFailure(new IllegalArgumentException(s"can't send payment: unknown invoice features (${r.paymentRequest.get.features})")) :: Nil)
case Some(invoice) if invoice.features.allowMultiPart =>
r.predefinedRoute match {
case Nil => spawnMultiPartPaymentFsm(paymentCfg) forward SendMultiPartPayment(r.paymentHash, invoice.paymentSecret.get, r.targetNodeId, r.amount, finalExpiry, r.maxAttempts, r.assistedRoutes, r.routeParams)
case hops => spawnPaymentFsm(paymentCfg) forward SendPaymentToRoute(r.paymentHash, hops, Onion.createMultiPartPayload(r.amount, invoice.amount.getOrElse(r.amount), finalExpiry, invoice.paymentSecret.get))
}
case _ =>
val payFsm = spawnPaymentFsm(paymentCfg)
// NB: we only generate legacy payment onions for now for maximum compatibility.
r.predefinedRoute match {
case Nil => payFsm forward SendPayment(r.paymentHash, r.targetNodeId, FinalLegacyPayload(r.amount, finalExpiry), r.maxAttempts, r.assistedRoutes, r.routeParams)
case hops => payFsm forward SendPaymentToRoute(r.paymentHash, hops, FinalLegacyPayload(r.amount, finalExpiry))
}
}
case r: SendTrampolinePaymentRequest =>

View file

@ -32,6 +32,8 @@ import fr.acinq.eclair.payment.send.PaymentLifecycle._
import fr.acinq.eclair.router._
import fr.acinq.eclair.wire.Onion._
import fr.acinq.eclair.wire._
import kamon.Kamon
import kamon.trace.Span
import scala.compat.Platform
import scala.util.{Failure, Success}
@ -45,10 +47,26 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
val id = cfg.id
val paymentsDb = nodeParams.db.payments
private val span = Kamon.runWithContextEntry(MultiPartPaymentLifecycle.parentPaymentIdKey, cfg.parentId) {
val spanBuilder = if (Kamon.currentSpan().isEmpty) {
Kamon.spanBuilder("single-payment")
} else {
Kamon.spanBuilder("payment-part").asChildOf(Kamon.currentSpan())
}
spanBuilder
.tag("paymentId", cfg.id.toString)
.tag("paymentHash", cfg.paymentHash.toHex)
.tag("targetNodeId", cfg.targetNodeId.toString())
.start()
}
startWith(WAITING_FOR_REQUEST, WaitingForRequest)
when(WAITING_FOR_REQUEST) {
case Event(c: SendPaymentToRoute, WaitingForRequest) =>
span.tag("amount", c.finalPayload.amount.toLong)
span.tag("totalAmount", c.finalPayload.totalAmount.toLong)
span.tag("expiry", c.finalPayload.expiry.toLong)
log.debug("sending {} to route {}", c.finalPayload.amount, c.hops.mkString("->"))
val send = SendPayment(c.paymentHash, c.hops.last, c.finalPayload, maxAttempts = 1)
router ! FinalizeRoute(c.hops)
@ -58,6 +76,9 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
goto(WAITING_FOR_ROUTE) using WaitingForRoute(sender, send, failures = Nil)
case Event(c: SendPayment, WaitingForRequest) =>
span.tag("amount", c.finalPayload.amount.toLong)
span.tag("totalAmount", c.finalPayload.totalAmount.toLong)
span.tag("expiry", c.finalPayload.expiry.toLong)
log.debug("sending {} to {}{}", c.finalPayload.amount, c.targetNodeId, c.routePrefix.mkString(" with route prefix ", "->", ""))
// We don't want the router to try cycling back to nodes that are at the beginning of the route.
val ignoredNodes = c.routePrefix.map(_.nodeId).toSet
@ -84,7 +105,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
case Event(Status.Failure(t), WaitingForRoute(s, c, failures)) =>
onFailure(s, PaymentFailed(id, c.paymentHash, failures :+ LocalFailure(t)))
stop(FSM.Normal)
myStop()
}
when(WAITING_FOR_PAYMENT_COMPLETE) {
@ -93,7 +114,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
case Event(fulfill: UpdateFulfillHtlc, WaitingForComplete(s, c, cmd, _, _, _, _, route)) =>
val p = PartialPayment(id, c.finalPayload.amount, cmd.amount - c.finalPayload.amount, fulfill.channelId, Some(route))
onSuccess(s, PaymentSent(id, c.paymentHash, fulfill.paymentPreimage, p :: Nil))
stop(FSM.Normal)
myStop()
case Event(fail: UpdateFailHtlc, WaitingForComplete(s, c, _, failures, sharedSecrets, ignoreNodes, ignoreChannels, hops)) =>
Sphinx.FailurePacket.decrypt(fail.reason, sharedSecrets) match {
@ -101,7 +122,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
// if destination node returns an error, we fail the payment immediately
log.warning(s"received an error message from target nodeId=$nodeId, failing the payment (failure=$failureMessage)")
onFailure(s, PaymentFailed(id, c.paymentHash, failures :+ RemoteFailure(hops, e)))
stop(FSM.Normal)
myStop()
case res if failures.size + 1 >= c.maxAttempts =>
// otherwise we never try more than maxAttempts, no matter the kind of error returned
val failure = res match {
@ -114,7 +135,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
}
log.warning(s"too many failed attempts, failing the payment")
onFailure(s, PaymentFailed(id, c.paymentHash, failures :+ failure))
stop(FSM.Normal)
myStop()
case Failure(t) =>
log.warning(s"cannot parse returned error: ${t.getMessage}")
// in that case we don't know which node is sending garbage, let's try to blacklist all nodes except the one we are directly connected to and the destination node
@ -186,22 +207,47 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
stay
case Event(Status.Failure(t), WaitingForComplete(s, c, _, failures, _, ignoreNodes, ignoreChannels, hops)) =>
if (failures.size + 1 >= c.maxAttempts) {
// If the first hop was selected by the sender (in routePrefix) and it failed, it doesn't make sense to retry (we
// will end up retrying over that same faulty channel).
if (failures.size + 1 >= c.maxAttempts || c.routePrefix.nonEmpty) {
onFailure(s, PaymentFailed(id, c.paymentHash, failures :+ LocalFailure(t)))
stop(FSM.Normal)
myStop()
} else {
log.info(s"received an error message from local, trying to use a different channel (failure=${t.getMessage})")
val faultyChannel = ChannelDesc(hops.head.lastUpdate.shortChannelId, hops.head.nodeId, hops.head.nextNodeId)
router ! RouteRequest(c.getRouteRequestStart(nodeParams), c.targetNodeId, c.finalPayload.amount, c.assistedRoutes, ignoreNodes, ignoreChannels + faultyChannel, c.routeParams)
goto(WAITING_FOR_ROUTE) using WaitingForRoute(s, c, failures :+ LocalFailure(t))
}
}
private var stateSpan: Option[Span] = None
onTransition {
case _ -> state2 =>
// whenever there is a transition we stop the current span and start a new one, this way we can track each state
val stateSpanBuilder = Kamon.spanBuilder(state2.toString).asChildOf(span)
nextStateData match {
case d: WaitingForRoute =>
// this means that previous state was WAITING_FOR_COMPLETE
d.failures.lastOption.foreach(failure => stateSpan.foreach(span => KamonExt.failSpan(span, failure)))
case d: WaitingForComplete =>
stateSpanBuilder.tag("route", s"${d.hops.map(_.nextNodeId).mkString("->")}")
case _ => ()
}
stateSpan.foreach(_.finish())
stateSpan = Some(stateSpanBuilder.start())
}
whenUnhandled {
case Event(_: TransportHandler.ReadAck, _) => stay // ignored, router replies with this when we forward a channel_update
}
def myStop(): State = {
stateSpan.foreach(_.finish())
span.finish()
stop(FSM.Normal)
}
def onSuccess(sender: ActorRef, result: PaymentSent): Unit = {
if (cfg.storeInDb) paymentsDb.updateOutgoingPayment(result)
sender ! result
@ -209,6 +255,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
}
def onFailure(sender: ActorRef, result: PaymentFailed): Unit = {
span.fail("payment failed")
if (cfg.storeInDb) paymentsDb.updateOutgoingPayment(result)
sender ! result
if (cfg.publishEvent) context.system.eventStream.publish(result)

View file

@ -2,13 +2,14 @@ package kamon
import kamon.context.Context
import kamon.tag.TagSet
import kamon.trace.Span
/**
* Kamon does not work on Android and using it would not make sense anyway, we use this simplistic mocks instead
*/
object Kamon {
object Mock {
object Mock extends Span {
def start() = this
def stop() = this
@ -26,6 +27,10 @@ object Kamon {
def decrement() = this
def record(a: Long) = this
def tag(a: String, b: Any) = this
def asChildOf(a: AnyRef) = this
}
def timer(name: String) = Mock
@ -41,4 +46,6 @@ object Kamon {
def runWithContextEntry[T, K](key: Context.Key[K], value: K)(f: => T): T = f
def runWithSpan[T](span: Any, finishSpan: Boolean)(f: => T): T = f
def currentSpan() = Some(Mock)
}

View file

@ -0,0 +1,7 @@
package kamon.trace
trait Span {
def fail(s: String): Unit = ()
def fail(s: String, t: AnyRef): Unit = ()
def finish(): Span
}

View file

@ -32,7 +32,7 @@ import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceivePayment
import fr.acinq.eclair.payment.receive.PaymentHandler
import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentRequest
import fr.acinq.eclair.router.RouteCalculationSpec.makeUpdate
import fr.acinq.eclair.router.{Announcements, PublicChannel, Router}
import fr.acinq.eclair.router.{Announcements, PublicChannel, Router, GetNetworkStats, NetworkStats, Stats}
import org.mockito.Mockito
import org.mockito.scalatest.IdiomaticMockito
import org.scalatest.{Outcome, ParallelTestExecution, fixture}
@ -177,7 +177,7 @@ class EclairImplSpec extends TestKit(ActorSystem("test")) with fixture.FunSuiteL
channels = channels + (desc.shortChannelId -> PublicChannel(ann, ByteVector32.Zeroes, 100 sat, Some(update.copy(channelFlags = 0)), None))
}
val mockNetworkDb = mock[NetworkDb]
val mockNetworkDb = mock[NetworkDb](withSettings.lenient()) // on Android listNodes() and removeNode() are not used
mockNetworkDb.listChannels() returns channels
mockNetworkDb.listNodes() returns Seq.empty
Mockito.doNothing().when(mockNetworkDb).removeNode(kit.nodeParams.nodeId)
@ -200,6 +200,28 @@ class EclairImplSpec extends TestKit(ActorSystem("test")) with fixture.FunSuiteL
})
}
test("router returns Network Stats") { f=>
import f._
val capStat=Stats(30 sat, 12 sat, 14 sat, 20 sat, 40 sat, 46 sat, 48 sat)
val cltvStat=Stats(CltvExpiryDelta(32), CltvExpiryDelta(11), CltvExpiryDelta(13), CltvExpiryDelta(22), CltvExpiryDelta(42), CltvExpiryDelta(51), CltvExpiryDelta(53))
val feeBaseStat=Stats(32 msat, 11 msat, 13 msat, 22 msat, 42 msat, 51 msat, 53 msat)
val feePropStat=Stats(32l, 11l, 13l, 22l, 42l, 51l, 53l)
val eclair = new EclairImpl(kit)
val fResp = eclair.networkStats()
f.router.expectMsg(GetNetworkStats)
f.router.reply(Some(new NetworkStats(1,2,capStat,cltvStat,feeBaseStat,feePropStat)))
awaitCond({
fResp.value match {
case Some(Success(Some(res))) => res.channels == 1
case _ => false
}
})
}
test("close and forceclose should work both with channelId and shortChannelId") { f =>
import f._

View file

@ -35,10 +35,10 @@ import fr.acinq.eclair.crypto.Sphinx.DecryptedFailurePacket
import fr.acinq.eclair.db.{IncomingPayment, IncomingPaymentStatus, OutgoingPaymentStatus}
import fr.acinq.eclair.io.Peer
import fr.acinq.eclair.io.Peer.{Disconnect, PeerRoutingMessage}
import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, OutgoingChannels}
import fr.acinq.eclair.payment._
import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceivePayment
import fr.acinq.eclair.payment.receive.{ForwardHandler, PaymentHandler}
import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, OutgoingChannels}
import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentRequest
import fr.acinq.eclair.payment.send.PaymentLifecycle.{State => _}
import fr.acinq.eclair.router.Graph.WeightRatios

View file

@ -25,8 +25,8 @@ import fr.acinq.bitcoin.{Block, Crypto, DeterministicWallet, Satoshi, Transactio
import fr.acinq.eclair.TestConstants.TestFeeEstimator
import fr.acinq.eclair._
import fr.acinq.eclair.blockchain.fee.FeeratesPerKw
import fr.acinq.eclair.channel.Commitments
import fr.acinq.eclair.channel.Helpers.Funding
import fr.acinq.eclair.channel.{ChannelFlags, Commitments}
import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.payment.PaymentSent.PartialPayment
import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, OutgoingChannel, OutgoingChannels}
@ -101,7 +101,7 @@ class MultiPartPaymentLifecycleSpec extends TestKit(ActorSystem("test")) with fi
router.send(payFsm, GetNetworkStatsResponse(Some(emptyStats)))
relayer.expectMsg(GetOutgoingChannels())
awaitCond(payFsm.stateName === WAIT_FOR_CHANNEL_BALANCES)
assert(payFsm.stateData.asInstanceOf[PaymentProgress].networkStats === Some(emptyStats))
assert(payFsm.stateData.asInstanceOf[WaitingForChannelBalances].networkStats === Some(emptyStats))
}
test("get network statistics not available") { f =>
@ -118,7 +118,7 @@ class MultiPartPaymentLifecycleSpec extends TestKit(ActorSystem("test")) with fi
router.expectMsg(TickComputeNetworkStats)
relayer.expectMsg(GetOutgoingChannels())
awaitCond(payFsm.stateName === WAIT_FOR_CHANNEL_BALANCES)
assert(payFsm.stateData.asInstanceOf[PaymentProgress].networkStats === None)
assert(payFsm.stateData.asInstanceOf[WaitingForChannelBalances].networkStats === None)
relayer.send(payFsm, localChannels())
awaitCond(payFsm.stateName === PAYMENT_IN_PROGRESS)
@ -129,14 +129,22 @@ class MultiPartPaymentLifecycleSpec extends TestKit(ActorSystem("test")) with fi
test("send to peer node via multiple channels") { f =>
import f._
val payment = SendMultiPartPayment(paymentHash, randomBytes32, b, 2000 * 1000 msat, expiry, 1)
val payment = SendMultiPartPayment(paymentHash, randomBytes32, b, 2000 * 1000 msat, expiry, 3)
// When sending to a peer node, we should not filter out unannounced channels.
val channels = OutgoingChannels(Seq(
OutgoingChannel(c, channelUpdate_ac_2, makeCommitments(1000 * 1000 msat, 0)),
OutgoingChannel(c, channelUpdate_ac_3, makeCommitments(1500 * 1000 msat, 0)),
OutgoingChannel(b, channelUpdate_ab_1.copy(channelFlags = ChannelFlags.Empty), makeCommitments(1000 * 1000 msat, 0, announceChannel = false)),
OutgoingChannel(b, channelUpdate_ab_2.copy(channelFlags = ChannelFlags.Empty), makeCommitments(1500 * 1000 msat, 0, announceChannel = false)),
OutgoingChannel(d, channelUpdate_ad_1, makeCommitments(1000 * 1000 msat, 0))))
// Network statistics should be ignored when sending to peer.
initPayment(f, payment, emptyStats, localChannels(0))
initPayment(f, payment, emptyStats, channels)
// The payment should be split in two, using direct channels with b.
// MaxAttempts should be set to 1 when using direct channels to the destination.
childPayFsm.expectMsgAllOf(
SendPayment(paymentHash, b, Onion.createMultiPartPayload(1000 * 1000 msat, payment.totalAmount, expiry, payment.paymentSecret), 1, routePrefix = Seq(ChannelHop(nodeParams.nodeId, b, channelUpdate_ab_1))),
SendPayment(paymentHash, b, Onion.createMultiPartPayload(1000 * 1000 msat, payment.totalAmount, expiry, payment.paymentSecret), 1, routePrefix = Seq(ChannelHop(nodeParams.nodeId, b, channelUpdate_ab_2)))
SendPayment(paymentHash, b, Onion.createMultiPartPayload(1000 * 1000 msat, payment.totalAmount, expiry, payment.paymentSecret), 1, routePrefix = Seq(ChannelHop(nodeParams.nodeId, b, channelUpdate_ab_1.copy(channelFlags = ChannelFlags.Empty)))),
SendPayment(paymentHash, b, Onion.createMultiPartPayload(1000 * 1000 msat, payment.totalAmount, expiry, payment.paymentSecret), 1, routePrefix = Seq(ChannelHop(nodeParams.nodeId, b, channelUpdate_ab_2.copy(channelFlags = ChannelFlags.Empty))))
)
childPayFsm.expectNoMsg(50 millis)
val childIds = payFsm.stateData.asInstanceOf[PaymentProgress].pending.keys.toSeq
@ -302,6 +310,23 @@ class MultiPartPaymentLifecycleSpec extends TestKit(ActorSystem("test")) with fi
assert(result.amount === payment.totalAmount)
}
test("skip unannounced channels when sending to remote node") { f =>
import f._
// The channels to b are not announced: they should be ignored so the payment should fail.
val channels = OutgoingChannels(Seq(
OutgoingChannel(b, channelUpdate_ab_1.copy(channelFlags = ChannelFlags.Empty), makeCommitments(1000 * 1000 msat, 10, announceChannel = false)),
OutgoingChannel(b, channelUpdate_ab_2.copy(channelFlags = ChannelFlags.Empty), makeCommitments(1500 * 1000 msat, 10, announceChannel = false)),
OutgoingChannel(c, channelUpdate_ac_1, makeCommitments(500 * 1000 msat, 10))
))
val payment = SendMultiPartPayment(paymentHash, randomBytes32, e, 1200 * 1000 msat, expiry, 3)
initPayment(f, payment, emptyStats.copy(capacity = Stats(Seq(1000), d => Satoshi(d.toLong))), channels)
val result = sender.expectMsgType[PaymentFailed]
assert(result.id === paymentId)
assert(result.paymentHash === paymentHash)
}
test("retry after error") { f =>
import f._
val payment = SendMultiPartPayment(paymentHash, randomBytes32, e, 3000 * 1000 msat, expiry, 3)
@ -310,20 +335,29 @@ class MultiPartPaymentLifecycleSpec extends TestKit(ActorSystem("test")) with fi
initPayment(f, payment, emptyStats.copy(capacity = Stats(Seq(1000), d => Satoshi(d.toLong))), testChannels)
waitUntilAmountSent(f, payment.totalAmount)
val pending = payFsm.stateData.asInstanceOf[PaymentProgress].pending
val childIds = pending.keys.toSeq
assert(pending.size > 2)
// Simulate two failures.
val failures = Seq(LocalFailure(new RuntimeException("418 I'm a teapot")), UnreadableRemoteFailure(Nil))
childPayFsm.send(payFsm, PaymentFailed(childIds.head, paymentHash, failures.slice(0, 1)))
childPayFsm.send(payFsm, PaymentFailed(childIds(1), paymentHash, failures.slice(1, 2)))
// Simulate a local channel failure and a remote failure.
val faultyLocalChannelId = getFirstHopShortChannelId(pending.head._2)
val faultyLocalPayments = pending.filter { case (_, p) => getFirstHopShortChannelId(p) == faultyLocalChannelId }
val faultyRemotePayment = pending.filter { case (_, p) => getFirstHopShortChannelId(p) != faultyLocalChannelId }.head
faultyLocalPayments.keys.foreach(id => {
childPayFsm.send(payFsm, PaymentFailed(id, paymentHash, LocalFailure(RouteNotFound) :: Nil))
})
childPayFsm.send(payFsm, PaymentFailed(faultyRemotePayment._1, paymentHash, UnreadableRemoteFailure(Nil) :: Nil))
// We should ask for updated balance to take into account pending payments.
relayer.expectMsg(GetOutgoingChannels())
relayer.send(payFsm, testChannels.copy(channels = testChannels.channels.dropRight(2)))
// The channel that lead to a RouteNotFound should be ignored.
assert(payFsm.stateData.asInstanceOf[PaymentProgress].ignoreChannels === Set(faultyLocalChannelId))
// New payments should be sent that match the failed amount.
waitUntilAmountSent(f, pending(childIds.head).finalPayload.amount + pending(childIds(1)).finalPayload.amount)
assert(payFsm.stateData.asInstanceOf[PaymentProgress].failures.toSet === failures.toSet)
waitUntilAmountSent(f, faultyRemotePayment._2.finalPayload.amount + faultyLocalPayments.values.map(_.finalPayload.amount).sum)
val stateData = payFsm.stateData.asInstanceOf[PaymentProgress]
assert(stateData.failures.toSet === Set(LocalFailure(RouteNotFound), UnreadableRemoteFailure(Nil)))
assert(stateData.pending.values.forall(p => getFirstHopShortChannelId(p) != faultyLocalChannelId))
}
test("cannot send (not enough capacity on local channels)") { f =>
@ -338,7 +372,7 @@ class MultiPartPaymentLifecycleSpec extends TestKit(ActorSystem("test")) with fi
assert(result.id === paymentId)
assert(result.paymentHash === paymentHash)
assert(result.failures.length === 1)
assert(result.failures.head.asInstanceOf[LocalFailure].t.getMessage === "balance is too low")
assert(result.failures.head.asInstanceOf[LocalFailure].t === BalanceTooLow)
}
test("cannot send (fee rate too high)") { f =>
@ -353,7 +387,7 @@ class MultiPartPaymentLifecycleSpec extends TestKit(ActorSystem("test")) with fi
assert(result.id === paymentId)
assert(result.paymentHash === paymentHash)
assert(result.failures.length === 1)
assert(result.failures.head.asInstanceOf[LocalFailure].t.getMessage === "balance is too low")
assert(result.failures.head.asInstanceOf[LocalFailure].t === BalanceTooLow)
}
test("payment timeout") { f =>
@ -396,7 +430,7 @@ class MultiPartPaymentLifecycleSpec extends TestKit(ActorSystem("test")) with fi
assert(result.paymentHash === paymentHash)
assert(result.failures.length === 3)
assert(result.failures.slice(0, 2) === failures)
assert(result.failures.last.asInstanceOf[LocalFailure].t.getMessage === "payment attempts exhausted without success")
assert(result.failures.last.asInstanceOf[LocalFailure].t === RetryExhausted)
}
test("receive partial failure after success (recipient spec violation)") { f =>
@ -495,7 +529,7 @@ object MultiPartPaymentLifecycleSpec {
val channelId_ac_2 = ShortChannelId(12)
val channelId_ac_3 = ShortChannelId(13)
val channelId_ad_1 = ShortChannelId(21)
val defaultChannelUpdate = ChannelUpdate(randomBytes64, Block.RegtestGenesisBlock.hash, ShortChannelId(0), 0, 1, 0, CltvExpiryDelta(12), 1 msat, 0 msat, 0, Some(2000 * 1000 msat))
val defaultChannelUpdate = ChannelUpdate(randomBytes64, Block.RegtestGenesisBlock.hash, ShortChannelId(0), 0, 1, ChannelFlags.AnnounceChannel, CltvExpiryDelta(12), 1 msat, 0 msat, 0, Some(2000 * 1000 msat))
val channelUpdate_ab_1 = defaultChannelUpdate.copy(shortChannelId = channelId_ab_1, cltvExpiryDelta = CltvExpiryDelta(4), feeBaseMsat = 100 msat, feeProportionalMillionths = 70)
val channelUpdate_ab_2 = defaultChannelUpdate.copy(shortChannelId = channelId_ab_2, cltvExpiryDelta = CltvExpiryDelta(4), feeBaseMsat = 100 msat, feeProportionalMillionths = 70)
val channelUpdate_ac_1 = defaultChannelUpdate.copy(shortChannelId = channelId_ac_1, cltvExpiryDelta = CltvExpiryDelta(5), feeBaseMsat = 150 msat, feeProportionalMillionths = 40)
@ -518,7 +552,7 @@ object MultiPartPaymentLifecycleSpec {
val emptyStats = NetworkStats(0, 0, Stats(Seq(0), d => Satoshi(d.toLong)), Stats(Seq(0), d => CltvExpiryDelta(d.toInt)), Stats(Seq(0), d => MilliSatoshi(d.toLong)), Stats(Seq(0), d => d.toLong))
def makeCommitments(canSend: MilliSatoshi, feeRatePerKw: Long): Commitments = {
def makeCommitments(canSend: MilliSatoshi, feeRatePerKw: Long, announceChannel: Boolean = true): Commitments = {
import fr.acinq.eclair.channel._
import fr.acinq.eclair.crypto.ShaChain
// We are only interested in availableBalanceForSend so we can put dummy values in most places.
@ -529,7 +563,7 @@ object MultiPartPaymentLifecycleSpec {
ChannelVersion.STANDARD,
localParams,
remoteParams,
channelFlags = 0x01.toByte,
channelFlags = if (announceChannel) ChannelFlags.AnnounceChannel else ChannelFlags.Empty,
LocalCommit(0, CommitmentSpec(Set.empty, feeRatePerKw, canSend, 0 msat), PublishableTxs(CommitTx(commitmentInput, Transaction(2, Nil, Nil, 0)), Nil)),
RemoteCommit(0, CommitmentSpec(Set.empty, feeRatePerKw, 0 msat, canSend), randomBytes32, randomKey.publicKey),
LocalChanges(Nil, Nil, Nil),

View file

@ -153,8 +153,8 @@ class PaymentLifecycleSpec extends BaseRouterSpec {
routerForwarder.send(paymentFSM, RouteResponse(Seq(ChannelHop(c, d, channelUpdate_cd)), Set(a, b), Set.empty))
val Transition(_, WAITING_FOR_ROUTE, WAITING_FOR_PAYMENT_COMPLETE) = monitor.expectMsgClass(classOf[Transition[_]])
sender.send(paymentFSM, UpdateFailMalformedHtlc(randomBytes32, 0, randomBytes32, 0))
routerForwarder.expectMsg(RouteRequest(c, d, defaultAmountMsat, ignoreNodes = Set(a, b), ignoreChannels = Set(ChannelDesc(channelUpdate_ab.shortChannelId, a, b))))
sender.send(paymentFSM, UpdateFailHtlc(randomBytes32, 0, randomBytes(Sphinx.FailurePacket.PacketLength)))
routerForwarder.expectMsg(RouteRequest(c, d, defaultAmountMsat, ignoreNodes = Set(a, b, c)))
val Transition(_, WAITING_FOR_PAYMENT_COMPLETE, WAITING_FOR_ROUTE) = monitor.expectMsgClass(classOf[Transition[_]])
assert(nodeParams.db.payments.getOutgoingPayment(id).exists(_.status == OutgoingPaymentStatus.Pending))
}

View file

@ -125,5 +125,11 @@
<version>1.4.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.spray</groupId>
<artifactId>spray-testkit_${scala.version.short}</artifactId>
<version>1.3.3</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View file

@ -2,6 +2,20 @@ eclair {
enable-kamon = false
}
kamon.instrumentation.akka {
filters {
actors {
# Decides which actors generate Spans for the messages they process, given that there is already an ongoing trace
# in the Context of the processed message (i.e. there is a Sampled Span in the Context).
#
trace {
includes = [ ]
excludes = [ "**" ] # we don't want automatically generated spans because they conflict with the ones we define
}
}
}
}
akka {
loggers = ["akka.event.slf4j.Slf4jLogger"]

View file

@ -21,7 +21,7 @@ import java.io.File
import akka.actor.{ActorSystem, Props, SupervisorStrategy}
import akka.io.IO
import com.typesafe.config.Config
import fr.acinq.eclair.api.Service
import fr.acinq.eclair.api.{Service, ServiceActor}
import grizzled.slf4j.Logging
import spray.can.Http
@ -68,7 +68,7 @@ object Boot extends App with Logging {
case "" => throw EmptyAPIPasswordException
case valid => valid
}
val serviceActor = system.actorOf(SimpleSupervisor.props(Props(new Service(apiPassword, new EclairImpl(kit))), "api-service", SupervisorStrategy.Restart))
val serviceActor = system.actorOf(SimpleSupervisor.props(Props(new ServiceActor(apiPassword, new EclairImpl(kit))), "api-service", SupervisorStrategy.Restart))
IO(Http) ! Http.Bind(serviceActor, config.getString("api.binding-ip"), config.getInt("api.port"))
} else {
logger.info("json API disabled")

View file

@ -18,7 +18,6 @@ package fr.acinq.eclair.api
import java.util.UUID
import akka.actor.{Actor, ActorSystem, Props}
import akka.util.Timeout
import com.google.common.net.HostAndPort
import fr.acinq.bitcoin.Crypto.PublicKey
@ -36,20 +35,22 @@ import spray.http.CacheDirectives._
import spray.routing.authentication.{BasicAuth, UserPass}
import spray.routing.{ExceptionHandler, HttpServiceActor, MalformedFormFieldRejection, Route}
import scala.concurrent.Future
import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.duration._
case class ErrorResponse(error: String)
class Service(password: String, eclairApi: Eclair)(implicit actorSystem: ActorSystem) extends HttpServiceActor with ExtraDirectives with Logging {
trait Service extends ExtraDirectives with Logging {
import JsonSupport.{json4sFormats, serialization, json4sMarshaller}
implicit val ec = actorSystem.dispatcher
implicit val ec = ExecutionContext.global
implicit val timeout = Timeout(30 seconds)
val apiExceptionHandler = ExceptionHandler {
val password: String
val eclairApi: Eclair
implicit val apiExceptionHandler = ExceptionHandler {
case t: IllegalArgumentException =>
logger.error(s"API call failed with cause=${t.getMessage}", t)
complete(StatusCodes.BadRequest, ErrorResponse(t.getMessage))
@ -64,11 +65,9 @@ class Service(password: String, eclairApi: Eclair)(implicit actorSystem: ActorSy
def userPassAuthenticator(userPass: Option[UserPass]): Future[Option[String]] = userPass match {
case Some(UserPass(user, pass)) if pass == password => Future.successful(Some("user"))
case _ => akka.pattern.after(1 second, using = actorSystem.scheduler)(Future.successful(None))(actorSystem.dispatcher) // force a 1 sec pause to deter brute force
case _ => Future.successful(None)
}
override def receive: Receive = runRoute(route)
def route: Route = {
respondWithHeaders(customHeaders) {
handleExceptions(apiExceptionHandler) {
@ -188,7 +187,7 @@ class Service(password: String, eclairApi: Eclair)(implicit actorSystem: ActorSy
path("createinvoice") {
formFields("description".as[String], amountMsatFormParam_opt, "expireIn".as[Long].?, "fallbackAddress".as[String].?, "paymentPreimage".as[Option[ByteVector32]](sha256HashUnmarshaller), "allowMultiPart".as[Boolean].?) {
(desc, amountMsat, expire, fallBackAddress, paymentPreimage_opt, allowMultiPart_opt) =>
complete(eclairApi.receive(desc, amountMsat, expire, fallBackAddress, paymentPreimage_opt, allowMultiPart_opt.getOrElse(false)))
complete(eclairApi.receive(desc, amountMsat, expire, fallBackAddress, paymentPreimage_opt, allowMultiPart_opt.getOrElse(false)))
}
} ~
path("getinvoice") {
@ -228,13 +227,10 @@ class Service(password: String, eclairApi: Eclair)(implicit actorSystem: ActorSy
} ~
path("usablebalances") {
complete(eclairApi.usableBalances())
} ~
path("getnewaddress"){
complete(eclairApi.newAddress())
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,10 @@
package fr.acinq.eclair.api
import fr.acinq.eclair.Eclair
import spray.routing.HttpServiceActor
class ServiceActor(pass: String, eclair: Eclair) extends HttpServiceActor with Service {
override val password = pass
override val eclairApi = eclair
override def receive: Receive = runRoute(route)
}

View file

@ -0,0 +1 @@
{"channels":1,"nodes":2,"capacity":{"median":30,"percentile5":12,"percentile10":14,"percentile25":20,"percentile75":40,"percentile90":46,"percentile95":48},"cltvExpiryDelta":{"median":32,"percentile5":11,"percentile10":13,"percentile25":22,"percentile75":42,"percentile90":51,"percentile95":53},"feeBase":{"median":32,"percentile5":11,"percentile10":13,"percentile25":22,"percentile75":42,"percentile90":51,"percentile95":53},"feeProportional":{"median":32,"percentile5":11,"percentile10":13,"percentile25":22,"percentile75":42,"percentile90":51,"percentile95":53}}

View file

@ -1,487 +1,359 @@
///*
// * Copyright 2019 ACINQ SAS
// *
// * Licensed under the Apache License, Version 2.0 (the "License");
// * you may not use this file except in compliance with the License.
// * You may obtain a copy of the License at
// *
// * http://www.apache.org/licenses/LICENSE-2.0
// *
// * Unless required by applicable law or agreed to in writing, software
// * distributed under the License is distributed on an "AS IS" BASIS,
// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// * See the License for the specific language governing permissions and
// * limitations under the License.
// */
//
//package fr.acinq.eclair.api
//
//import java.util.UUID
//
//import akka.actor.ActorSystem
//import akka.http.scaladsl.model.FormData
//import akka.http.scaladsl.model.StatusCodes._
//import akka.http.scaladsl.model.headers.BasicHttpCredentials
//import akka.http.scaladsl.server.Route
//import akka.http.scaladsl.testkit.{RouteTestTimeout, ScalatestRouteTest, WSProbe}
//import akka.stream.ActorMaterializer
//import akka.util.Timeout
//import de.heikoseeberger.akkahttpjson4s.Json4sSupport
//import fr.acinq.bitcoin.Crypto.PublicKey
//import fr.acinq.bitcoin.{Block, ByteVector32}
//import fr.acinq.eclair._
//import fr.acinq.eclair.db.{IncomingPayment, IncomingPaymentStatus, OutgoingPayment, OutgoingPaymentStatus}
//import fr.acinq.eclair.io.NodeURI
//import fr.acinq.eclair.io.Peer.PeerInfo
//import fr.acinq.eclair.payment.Relayer.UsableBalance
//import fr.acinq.eclair.payment.{PaymentFailed, _}
//import fr.acinq.eclair.wire.NodeAddress
//import org.mockito.scalatest.IdiomaticMockito
//import org.scalatest.{FunSuite, Matchers}
//import scodec.bits._
//
//import scala.concurrent.Future
//import scala.concurrent.duration._
//import scala.io.Source
//import scala.reflect.ClassTag
//import scala.util.Try
//
//class ApiServiceSpec extends FunSuite with ScalatestRouteTest with IdiomaticMockito with Matchers {
//
// implicit val formats = JsonSupport.formats
// implicit val serialization = JsonSupport.serialization
// implicit val routeTestTimeout = RouteTestTimeout(3 seconds)
//
// val aliceNodeId = PublicKey(hex"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0")
// val bobNodeId = PublicKey(hex"039dc0e0b1d25905e44fdf6f8e89755a5e219685840d0bc1d28d3308f9628a3585")
//
// class MockService(eclair: Eclair) extends Service {
// override val eclairApi: Eclair = eclair
//
// override def password: String = "mock"
//
// override implicit val actorSystem: ActorSystem = system
// override implicit val mat: ActorMaterializer = materializer
// }
//
// test("API service should handle failures correctly") {
// val mockService = new MockService(mock[Eclair])
//
// // no auth
// Post("/getinfo") ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == Unauthorized)
// }
//
// // wrong auth
// Post("/getinfo") ~>
// addCredentials(BasicHttpCredentials("", mockService.password + "what!")) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == Unauthorized)
// }
//
// // correct auth but wrong URL
// Post("/mistake") ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == NotFound)
// }
//
// // wrong param type
// Post("/channel", FormData(Map("channelId" -> "hey")).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == BadRequest)
// val resp = entityAs[ErrorResponse](Json4sSupport.unmarshaller, ClassTag(classOf[ErrorResponse]))
// assert(resp.error == "The form field 'channelId' was malformed:\nInvalid hexadecimal character 'h' at index 0")
// }
//
// // wrong params
// Post("/connect", FormData("urb" -> "030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87@93.137.102.239:9735").toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == BadRequest)
// }
// }
//
// test("'peers' should ask the switchboard for current known peers") {
// val eclair = mock[Eclair]
// val mockService = new MockService(eclair)
// eclair.peersInfo()(any[Timeout]) returns Future.successful(List(
// PeerInfo(
// nodeId = aliceNodeId,
// state = "CONNECTED",
// address = Some(NodeAddress.fromParts("localhost", 9731).get.socketAddress),
// channels = 1),
// PeerInfo(
// nodeId = bobNodeId,
// state = "DISCONNECTED",
// address = None,
// channels = 1)))
//
// Post("/peers") ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// val response = entityAs[String]
// eclair.peersInfo()(any[Timeout]).wasCalled(once)
// matchTestJson("peers", response)
// }
// }
//
// test("'usablebalances' asks relayer for current usable balances") {
// val eclair = mock[Eclair]
// val mockService = new MockService(eclair)
// eclair.usableBalances()(any[Timeout]) returns Future.successful(List(
// UsableBalance(aliceNodeId, ShortChannelId(1), 100000000 msat, 20000000 msat, isPublic = true),
// UsableBalance(aliceNodeId, ShortChannelId(2), 400000000 msat, 30000000 msat, isPublic = false)
// ))
//
// Post("/usablebalances") ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// val response = entityAs[String]
// eclair.usableBalances()(any[Timeout]).wasCalled(once)
// matchTestJson("usablebalances", response)
// }
// }
//
// test("'getinfo' response should include this node ID") {
// val eclair = mock[Eclair]
// val mockService = new MockService(eclair)
// eclair.getInfoResponse()(any[Timeout]) returns Future.successful(GetInfoResponse(
// nodeId = aliceNodeId,
// alias = "alice",
// chainHash = ByteVector32(hex"06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f"),
// blockHeight = 9999,
// publicAddresses = NodeAddress.fromParts("localhost", 9731).get :: Nil
// ))
//
// Post("/getinfo") ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// val resp = entityAs[String]
// assert(resp.toString.contains(aliceNodeId.toString))
// eclair.getInfoResponse()(any[Timeout]).wasCalled(once)
// matchTestJson("getinfo", resp)
// }
// }
//
// test("'close' method should accept a channelId and shortChannelId") {
// val shortChannelIdSerialized = "42000x27x3"
// val channelId = "56d7d6eda04d80138270c49709f1eadb5ab4939e5061309ccdacdb98ce637d0e"
//
// val eclair = mock[Eclair]
// eclair.close(any, any)(any[Timeout]) returns Future.successful(aliceNodeId.toString())
// val mockService = new MockService(eclair)
//
// Post("/close", FormData("shortChannelId" -> shortChannelIdSerialized).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// addHeader("Content-Type", "application/json") ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// val resp = entityAs[String]
// assert(resp.contains(aliceNodeId.toString))
// eclair.close(Right(ShortChannelId(shortChannelIdSerialized)), None)(any[Timeout]).wasCalled(once)
// matchTestJson("close", resp)
// }
//
// Post("/close", FormData("channelId" -> channelId).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// addHeader("Content-Type", "application/json") ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// val resp = entityAs[String]
// assert(resp.contains(aliceNodeId.toString))
// eclair.close(Left(ByteVector32.fromValidHex(channelId)), None)(any[Timeout]).wasCalled(once)
// matchTestJson("close", resp)
// }
// }
//
// test("'connect' method should accept an URI and a triple with nodeId/host/port") {
// val remoteNodeId = PublicKey(hex"030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87")
// val remoteUri = NodeURI.parse("030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87@93.137.102.239:9735")
//
// val eclair = mock[Eclair]
// eclair.connect(any[Either[NodeURI, PublicKey]])(any[Timeout]) returns Future.successful("connected")
// val mockService = new MockService(eclair)
//
// Post("/connect", FormData("nodeId" -> remoteNodeId.toString()).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// assert(entityAs[String] == "\"connected\"")
// eclair.connect(Right(remoteNodeId))(any[Timeout]).wasCalled(once)
// }
//
// Post("/connect", FormData("uri" -> remoteUri.toString).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// assert(entityAs[String] == "\"connected\"")
// eclair.connect(Left(remoteUri))(any[Timeout]).wasCalled(once) // must account for the previous, identical, invocation
// }
// }
//
// test("'send' method should handle payment failures") {
// val eclair = mock[Eclair]
// eclair.send(any, any, any, any, any, any, any, any)(any[Timeout]) returns Future.failed(new IllegalArgumentException("invoice has expired"))
// val mockService = new MockService(eclair)
//
// val invoice = "lnbc12580n1pw2ywztpp554ganw404sh4yjkwnysgn3wjcxfcq7gtx53gxczkjr9nlpc3hzvqdq2wpskwctddyxqr4rqrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glc7z9rtvqqwngqqqqqqqlgqqqqqeqqjqrrt8smgjvfj7sg38dwtr9kc9gg3era9k3t2hvq3cup0jvsrtrxuplevqgfhd3rzvhulgcxj97yjuj8gdx8mllwj4wzjd8gdjhpz3lpqqvk2plh"
//
// Post("/payinvoice", FormData("invoice" -> invoice).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == BadRequest)
// val resp = entityAs[ErrorResponse](Json4sSupport.unmarshaller, ClassTag(classOf[ErrorResponse]))
// assert(resp.error == "invoice has expired")
// eclair.send(None, any, 1258000 msat, any, any, any, any, any)(any[Timeout]).wasCalled(once)
// }
// }
//
// test("'send' method should correctly forward amount parameters to EclairImpl") {
// val invoice = "lnbc12580n1pw2ywztpp554ganw404sh4yjkwnysgn3wjcxfcq7gtx53gxczkjr9nlpc3hzvqdq2wpskwctddyxqr4rqrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glc7z9rtvqqwngqqqqqqqlgqqqqqeqqjqrrt8smgjvfj7sg38dwtr9kc9gg3era9k3t2hvq3cup0jvsrtrxuplevqgfhd3rzvhulgcxj97yjuj8gdx8mllwj4wzjd8gdjhpz3lpqqvk2plh"
//
// val eclair = mock[Eclair]
// eclair.send(any, any, any, any, any, any, any, any)(any[Timeout]) returns Future.successful(UUID.randomUUID())
// val mockService = new MockService(eclair)
//
// Post("/payinvoice", FormData("invoice" -> invoice).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// eclair.send(None, any, 1258000 msat, any, any, any, any, any)(any[Timeout]).wasCalled(once)
// }
//
// Post("/payinvoice", FormData("invoice" -> invoice, "amountMsat" -> "123", "feeThresholdSat" -> "112233", "maxFeePct" -> "2.34", "externalId" -> "42").toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// eclair.send(Some("42"), any, 123 msat, any, any, any, Some(112233 sat), Some(2.34))(any[Timeout]).wasCalled(once)
// }
// }
//
// test("'getreceivedinfo'") {
// val invoice = "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp"
// val defaultPayment = IncomingPayment(PaymentRequest.read(invoice), ByteVector32.One, 42, IncomingPaymentStatus.Pending)
// val eclair = mock[Eclair]
// val notFound = randomBytes32
// eclair.receivedInfo(notFound)(any) returns Future.successful(None)
// val pending = randomBytes32
// eclair.receivedInfo(pending)(any) returns Future.successful(Some(defaultPayment))
// val expired = randomBytes32
// eclair.receivedInfo(expired)(any) returns Future.successful(Some(defaultPayment.copy(status = IncomingPaymentStatus.Expired)))
// val received = randomBytes32
// eclair.receivedInfo(received)(any) returns Future.successful(Some(defaultPayment.copy(status = IncomingPaymentStatus.Received(42 msat, 45))))
// val mockService = new MockService(eclair)
//
// Post("/getreceivedinfo", FormData("paymentHash" -> notFound.toHex).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == NotFound)
// val resp = entityAs[ErrorResponse](Json4sSupport.unmarshaller, ClassTag(classOf[ErrorResponse]))
// assert(resp == ErrorResponse("Not found"))
// eclair.receivedInfo(notFound)(any[Timeout]).wasCalled(once)
// }
//
// Post("/getreceivedinfo", FormData("paymentHash" -> pending.toHex).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// val response = entityAs[String]
// matchTestJson("received-pending", response)
// eclair.receivedInfo(pending)(any[Timeout]).wasCalled(once)
// }
//
// Post("/getreceivedinfo", FormData("paymentHash" -> expired.toHex).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// val response = entityAs[String]
// matchTestJson("received-expired", response)
// eclair.receivedInfo(expired)(any[Timeout]).wasCalled(once)
// }
//
// Post("/getreceivedinfo", FormData("paymentHash" -> received.toHex).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// val response = entityAs[String]
// matchTestJson("received-success", response)
// eclair.receivedInfo(received)(any[Timeout]).wasCalled(once)
// }
// }
//
// test("'getsentinfo'") {
// val defaultPayment = OutgoingPayment(UUID.fromString("00000000-0000-0000-0000-000000000000"), UUID.fromString("11111111-1111-1111-1111-111111111111"), None, ByteVector32.Zeroes, 42 msat, aliceNodeId, 1, None, OutgoingPaymentStatus.Pending)
// val eclair = mock[Eclair]
// val pending = UUID.randomUUID()
// eclair.sentInfo(Left(pending))(any) returns Future.successful(Seq(defaultPayment))
// val failed = UUID.randomUUID()
// eclair.sentInfo(Left(failed))(any) returns Future.successful(Seq(defaultPayment.copy(status = OutgoingPaymentStatus.Failed(Nil, 2))))
// val sent = UUID.randomUUID()
// eclair.sentInfo(Left(sent))(any) returns Future.successful(Seq(defaultPayment.copy(status = OutgoingPaymentStatus.Succeeded(ByteVector32.One, 5 msat, Nil, 3))))
// val mockService = new MockService(eclair)
//
// Post("/getsentinfo", FormData("id" -> pending.toString).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// val response = entityAs[String]
// matchTestJson("sent-pending", response)
// eclair.sentInfo(Left(pending))(any[Timeout]).wasCalled(once)
// }
//
// Post("/getsentinfo", FormData("id" -> failed.toString).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// val response = entityAs[String]
// matchTestJson("sent-failed", response)
// eclair.sentInfo(Left(failed))(any[Timeout]).wasCalled(once)
// }
//
// Post("/getsentinfo", FormData("id" -> sent.toString).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// val response = entityAs[String]
// matchTestJson("sent-success", response)
// eclair.sentInfo(Left(sent))(any[Timeout]).wasCalled(once)
// }
// }
//
// test("'sendtoroute' method should accept a both a json-encoded AND comma separated list of pubkeys") {
// val rawUUID = "487da196-a4dc-4b1e-92b4-3e5e905e9f3f"
// val paymentUUID = UUID.fromString(rawUUID)
// val externalId = UUID.randomUUID().toString
// val pr = PaymentRequest(Block.LivenetGenesisBlock.hash, Some(1234 msat), ByteVector32.Zeroes, randomKey, "Some invoice")
// val expectedRoute = List(PublicKey(hex"0217eb8243c95f5a3b7d4c5682d10de354b7007eb59b6807ae407823963c7547a9"), PublicKey(hex"0242a4ae0c5bef18048fbecf995094b74bfb0f7391418d71ed394784373f41e4f3"), PublicKey(hex"026ac9fcd64fb1aa1c491fc490634dc33da41d4a17b554e0adf1b32fee88ee9f28"))
// val csvNodes = "0217eb8243c95f5a3b7d4c5682d10de354b7007eb59b6807ae407823963c7547a9, 0242a4ae0c5bef18048fbecf995094b74bfb0f7391418d71ed394784373f41e4f3, 026ac9fcd64fb1aa1c491fc490634dc33da41d4a17b554e0adf1b32fee88ee9f28"
// val jsonNodes = serialization.write(expectedRoute)
//
// val eclair = mock[Eclair]
// eclair.sendToRoute(any[Option[String]], any[List[PublicKey]], any[MilliSatoshi], any[ByteVector32], any[CltvExpiryDelta], any[Option[PaymentRequest]])(any[Timeout]) returns Future.successful(paymentUUID)
// val mockService = new MockService(eclair)
//
// Post("/sendtoroute", FormData("route" -> jsonNodes, "amountMsat" -> "1234", "paymentHash" -> ByteVector32.Zeroes.toHex, "finalCltvExpiry" -> "190", "externalId" -> externalId.toString, "invoice" -> PaymentRequest.write(pr)).toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// addHeader("Content-Type", "application/json") ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// assert(entityAs[String] == "\"" + rawUUID + "\"")
// eclair.sendToRoute(Some(externalId), expectedRoute, 1234 msat, ByteVector32.Zeroes, CltvExpiryDelta(190), Some(pr))(any[Timeout]).wasCalled(once)
// }
//
// // this test uses CSV encoded route
// Post("/sendtoroute", FormData("route" -> csvNodes, "amountMsat" -> "1234", "paymentHash" -> ByteVector32.One.toHex, "finalCltvExpiry" -> "190").toEntity) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// addHeader("Content-Type", "application/json") ~>
// Route.seal(mockService.route) ~>
// check {
// assert(handled)
// assert(status == OK)
// assert(entityAs[String] == "\"" + rawUUID + "\"")
// eclair.sendToRoute(None, expectedRoute, 1234 msat, ByteVector32.One, CltvExpiryDelta(190), None)(any[Timeout]).wasCalled(once)
// }
// }
//
// test("the websocket should return typed objects") {
// val mockService = new MockService(mock[Eclair])
// val fixedUUID = UUID.fromString("487da196-a4dc-4b1e-92b4-3e5e905e9f3f")
// val wsClient = WSProbe()
//
// WS("/ws", wsClient.flow) ~>
// addCredentials(BasicHttpCredentials("", mockService.password)) ~>
// mockService.route ~>
// check {
// val pf = PaymentFailed(fixedUUID, ByteVector32.Zeroes, failures = Seq.empty, timestamp = 1553784963659L)
// val expectedSerializedPf = """{"type":"payment-failed","id":"487da196-a4dc-4b1e-92b4-3e5e905e9f3f","paymentHash":"0000000000000000000000000000000000000000000000000000000000000000","failures":[],"timestamp":1553784963659}"""
// assert(serialization.write(pf) === expectedSerializedPf)
// system.eventStream.publish(pf)
// wsClient.expectMessage(expectedSerializedPf)
//
// val ps = PaymentSent(fixedUUID, ByteVector32.Zeroes, ByteVector32.One, Seq(PaymentSent.PartialPayment(fixedUUID, 21 msat, 1 msat, ByteVector32.Zeroes, None, 1553784337711L)))
// val expectedSerializedPs = """{"type":"payment-sent","id":"487da196-a4dc-4b1e-92b4-3e5e905e9f3f","paymentHash":"0000000000000000000000000000000000000000000000000000000000000000","paymentPreimage":"0100000000000000000000000000000000000000000000000000000000000000","parts":[{"id":"487da196-a4dc-4b1e-92b4-3e5e905e9f3f","amount":21,"feesPaid":1,"toChannelId":"0000000000000000000000000000000000000000000000000000000000000000","timestamp":1553784337711}]}"""
// assert(serialization.write(ps) === expectedSerializedPs)
// system.eventStream.publish(ps)
// wsClient.expectMessage(expectedSerializedPs)
//
// val prel = PaymentRelayed(amountIn = 21 msat, amountOut = 20 msat, paymentHash = ByteVector32.Zeroes, fromChannelId = ByteVector32.Zeroes, ByteVector32.One, timestamp = 1553784963659L)
// val expectedSerializedPrel = """{"type":"payment-relayed","amountIn":21,"amountOut":20,"paymentHash":"0000000000000000000000000000000000000000000000000000000000000000","fromChannelId":"0000000000000000000000000000000000000000000000000000000000000000","toChannelId":"0100000000000000000000000000000000000000000000000000000000000000","timestamp":1553784963659}"""
// assert(serialization.write(prel) === expectedSerializedPrel)
// system.eventStream.publish(prel)
// wsClient.expectMessage(expectedSerializedPrel)
//
// val precv = PaymentReceived(ByteVector32.Zeroes, Seq(PaymentReceived.PartialPayment(21 msat, ByteVector32.Zeroes, 1553784963659L)))
// val expectedSerializedPrecv = """{"type":"payment-received","paymentHash":"0000000000000000000000000000000000000000000000000000000000000000","parts":[{"amount":21,"fromChannelId":"0000000000000000000000000000000000000000000000000000000000000000","timestamp":1553784963659}]}"""
// assert(serialization.write(precv) === expectedSerializedPrecv)
// system.eventStream.publish(precv)
// wsClient.expectMessage(expectedSerializedPrecv)
//
// val pset = PaymentSettlingOnChain(fixedUUID, amount = 21 msat, paymentHash = ByteVector32.One, timestamp = 1553785442676L)
// val expectedSerializedPset = """{"type":"payment-settling-onchain","id":"487da196-a4dc-4b1e-92b4-3e5e905e9f3f","amount":21,"paymentHash":"0100000000000000000000000000000000000000000000000000000000000000","timestamp":1553785442676}"""
// assert(serialization.write(pset) === expectedSerializedPset)
// system.eventStream.publish(pset)
// wsClient.expectMessage(expectedSerializedPset)
// }
// }
//
// private def matchTestJson(apiName: String, response: String) = {
// val resource = getClass.getResourceAsStream(s"/api/$apiName")
// val expectedResponse = Try(Source.fromInputStream(resource).mkString).getOrElse {
// throw new IllegalArgumentException(s"Mock file for $apiName not found")
// }
// assert(response == expectedResponse, s"Test mock for $apiName did not match the expected response")
// }
//
//}
/*
* Copyright 2019 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.acinq.eclair.api
import java.util.UUID
import akka.util.Timeout
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.eclair.{CltvExpiryDelta, Eclair, MilliSatoshi}
import fr.acinq.eclair._
import fr.acinq.eclair.io.NodeURI
import fr.acinq.eclair.io.Peer.PeerInfo
import fr.acinq.eclair.payment.relay.Relayer.UsableBalance
import fr.acinq.eclair.payment.{PaymentFailed, _}
import fr.acinq.eclair.wire.NodeAddress
import org.mockito.scalatest.IdiomaticMockito
import org.scalatest.{FunSuite, FunSuiteLike, Matchers}
import scodec.bits._
import spray.http.{BasicHttpCredentials, FormData, HttpResponse}
import spray.routing.{HttpService, Route, RoutingSettings}
import spray.testkit.{RouteTest, ScalatestRouteTest}
import spray.http.StatusCodes._
import spray.httpx.unmarshalling.{Deserializer, FromResponseUnmarshaller}
import scala.concurrent.Future
import scala.concurrent.duration._
import scala.io.Source
import scala.util.Try
class ApiServiceSpec extends FunSuite with ScalatestRouteTest with RouteTest with IdiomaticMockito with Matchers {
implicit val formats = JsonSupport.json4sJacksonFormats
implicit val serialization = JsonSupport.serialization
implicit val unmarshaller = JsonSupport.json4sUnmarshaller[ErrorResponse]
implicit val routeTestTimeout = RouteTestTimeout(3 seconds)
val mockPassword = "mockPassword"
val aliceNodeId = PublicKey(hex"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0")
val bobNodeId = PublicKey(hex"039dc0e0b1d25905e44fdf6f8e89755a5e219685840d0bc1d28d3308f9628a3585")
implicit val errorResponseDeserializer = Deserializer.fromFunction2Converter[HttpResponse, ErrorResponse] {
case HttpResponse(_, entity, _, _) => serialization.read[ErrorResponse](entity.asString)
case _ => ???
}
class MockService(eclair: Eclair) extends Service {
override val eclairApi: Eclair = eclair
override val password: String = mockPassword
}
test("API service should handle failures correctly") {
val service = new MockService(mock[Eclair])
// no auth
Post("/getinfo") ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == Unauthorized)
}
// wrong auth
Post("/getinfo") ~>
addCredentials(BasicHttpCredentials("", mockPassword + "what!")) ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == Unauthorized)
}
// correct auth but wrong URL
Post("/mistake") ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == NotFound)
}
// wrong param type
Post("/channel", FormData(Map("channelId" -> "hey"))) ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == BadRequest)
val resp = responseAs[String]
assert(resp == "The form field 'channelId' was malformed:\njava.lang.IllegalArgumentException: Invalid hexadecimal character 'h' at index 0")
}
// wrong params
Post("/connect", FormData(Map("urb" -> "030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87@93.137.102.239:9735"))) ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == InternalServerError)
}
}
test("'peers' should ask the switchboard for current known peers") {
val mockEclair = mock[Eclair]
val service = new MockService(mockEclair)
mockEclair.peersInfo()(any[Timeout]) returns Future.successful(List(
PeerInfo(
nodeId = aliceNodeId,
state = "CONNECTED",
address = Some(NodeAddress.fromParts("localhost", 9731).get.socketAddress),
channels = 1),
PeerInfo(
nodeId = bobNodeId,
state = "DISCONNECTED",
address = None,
channels = 1)))
Post("/peers") ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == OK)
val response = responseAs[String]
mockEclair.peersInfo()(any[Timeout]).wasCalled(once)
matchTestJson("peers", response)
}
}
test("'usablebalances' asks router for current usable balances") {
val mockEclair = mock[Eclair]
val service = new MockService(mockEclair)
mockEclair.usableBalances()(any[Timeout]) returns Future.successful(List(
UsableBalance(canSend = 100000000 msat, canReceive = 20000000 msat, shortChannelId = ShortChannelId(1), remoteNodeId = aliceNodeId, isPublic = true),
UsableBalance(canSend = 400000000 msat, canReceive = 30000000 msat, shortChannelId = ShortChannelId(2), remoteNodeId = aliceNodeId, isPublic = false)
))
Post("/usablebalances") ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == OK)
val response = responseAs[String]
mockEclair.usableBalances()(any[Timeout]).wasCalled(once)
matchTestJson("usablebalances", response)
}
}
test("'getinfo' response should include this node ID") {
val mockEclair = mock[Eclair]
val service = new MockService(mockEclair)
mockEclair.getInfoResponse()(any[Timeout]) returns Future.successful(GetInfoResponse(
nodeId = aliceNodeId,
alias = "alice",
chainHash = ByteVector32(hex"06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f"),
blockHeight = 9999,
publicAddresses = NodeAddress.fromParts("localhost", 9731).get :: Nil
))
Post("/getinfo") ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == OK)
val resp = responseAs[String]
assert(resp.toString.contains(aliceNodeId.toString))
mockEclair.getInfoResponse()(any[Timeout]).wasCalled(once)
matchTestJson("getinfo", resp)
}
}
test("'close' method should accept a channelId and shortChannelId") {
val shortChannelIdSerialized = "42000x27x3"
val channelId = "56d7d6eda04d80138270c49709f1eadb5ab4939e5061309ccdacdb98ce637d0e"
val mockEclair = mock[Eclair]
val service = new MockService(mockEclair)
mockEclair.close(any, any)(any[Timeout]) returns Future.successful(aliceNodeId.toString())
Post("/close", FormData(Map("shortChannelId" -> shortChannelIdSerialized))) ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
addHeader("Content-Type", "application/json") ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == OK)
val resp = responseAs[String]
assert(resp.contains(aliceNodeId.toString))
mockEclair.close(Right(ShortChannelId(shortChannelIdSerialized)), None)(any[Timeout]).wasCalled(once)
matchTestJson("close", resp)
}
Post("/close", FormData(Map("channelId" -> channelId))) ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
addHeader("Content-Type", "application/json") ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == OK)
val resp = responseAs[String]
assert(resp.contains(aliceNodeId.toString))
mockEclair.close(Left(ByteVector32.fromValidHex(channelId)), None)(any[Timeout]).wasCalled(once)
matchTestJson("close", resp)
}
}
test("'connect' method should accept an URI and a triple with nodeId/host/port") {
val remoteNodeId = PublicKey(hex"030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87")
val remoteUri = NodeURI.parse("030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87@93.137.102.239:9735")
val mockEclair = mock[Eclair]
mockEclair.connect(any[Either[NodeURI, PublicKey]])(any[Timeout]) returns Future.successful("connected")
val service = new MockService(mockEclair)
// Post("/connect", FormData(Map("nodeId" -> remoteNodeId.value.toHex))) ~>
// addCredentials(BasicHttpCredentials("", mockPassword)) ~>
// HttpService.sealRoute(service.route) ~>
// check {
// assert(handled)
// assert(responseAs[String] == "\"connected\"")
// assert(status == OK)
// mockEclair.connect(Right(remoteNodeId))(any[Timeout]).wasCalled(once)
// }
Post("/connect", FormData(Map("uri" -> remoteUri.toString))) ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == OK)
assert(responseAs[String] == "\"connected\"")
mockEclair.connect(Left(remoteUri))(any[Timeout]).wasCalled(once) // must account for the previous, identical, invocation
}
}
test("'send' method should handle payment failures") {
val mockEclair = mock[Eclair]
val service = new MockService(mockEclair)
mockEclair.send(any, any, any, any, any, any, any, any)(any[Timeout]) returns Future.failed(new IllegalArgumentException("invoice has expired"))
val invoice = "lnbc12580n1pw2ywztpp554ganw404sh4yjkwnysgn3wjcxfcq7gtx53gxczkjr9nlpc3hzvqdq2wpskwctddyxqr4rqrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glc7z9rtvqqwngqqqqqqqlgqqqqqeqqjqrrt8smgjvfj7sg38dwtr9kc9gg3era9k3t2hvq3cup0jvsrtrxuplevqgfhd3rzvhulgcxj97yjuj8gdx8mllwj4wzjd8gdjhpz3lpqqvk2plh"
Post("/payinvoice", FormData(Map("invoice" -> invoice))) ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == BadRequest)
val resp = responseAs[String]
assert(resp == "{\"error\":\"invoice has expired\"}")
mockEclair.send(None, any, 1258000 msat, any, any, any, any, any)(any[Timeout]).wasCalled(once)
}
}
test("'send' method should correctly forward amount parameters to EclairImpl") {
val invoice = "lnbc12580n1pw2ywztpp554ganw404sh4yjkwnysgn3wjcxfcq7gtx53gxczkjr9nlpc3hzvqdq2wpskwctddyxqr4rqrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glc7z9rtvqqwngqqqqqqqlgqqqqqeqqjqrrt8smgjvfj7sg38dwtr9kc9gg3era9k3t2hvq3cup0jvsrtrxuplevqgfhd3rzvhulgcxj97yjuj8gdx8mllwj4wzjd8gdjhpz3lpqqvk2plh"
val mockEclair = mock[Eclair]
val service = new MockService(mockEclair)
mockEclair.send(any, any, any, any, any, any, any, any)(any[Timeout]) returns Future.successful(UUID.randomUUID())
Post("/payinvoice", FormData(Map("invoice" -> invoice))) ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == OK)
mockEclair.send(None, any, 1258000 msat, any, any, any, any, any)(any[Timeout]).wasCalled(once)
}
Post("/payinvoice", FormData(Map("invoice" -> invoice, "amountMsat" -> "123", "feeThresholdSat" -> "112233", "maxFeePct" -> "2.34", "externalId" -> "42"))) ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == OK)
mockEclair.send(Some("42"), any, 123 msat, any, any, any, Some(112233 sat), Some(2.34))(any[Timeout]).wasCalled(once)
}
}
test("'getreceivedinfo' method should respond HTTP 404 with a JSON encoded response if the element is not found") {
val mockEclair = mock[Eclair]
val service = new MockService(mockEclair)
mockEclair.receivedInfo(any[ByteVector32])(any) returns Future.successful(None)
Post("/getreceivedinfo", FormData(Map("paymentHash" -> ByteVector32.Zeroes.toHex))) ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == NotFound)
val resp = responseAs[ErrorResponse] //(JsonSupport.unmarshaller, ClassTag(classOf[ErrorResponse]))
assert(resp == ErrorResponse("Not found"))
mockEclair.receivedInfo(ByteVector32.Zeroes)(any[Timeout]).wasCalled(once)
}
}
test("'sendtoroute' method should accept a both a json-encoded AND comma separaterd list of pubkeys") {
val rawUUID = "487da196-a4dc-4b1e-92b4-3e5e905e9f3f"
val paymentUUID = UUID.fromString(rawUUID)
val externalId = UUID.randomUUID().toString
val expectedRoute = List(PublicKey(hex"0217eb8243c95f5a3b7d4c5682d10de354b7007eb59b6807ae407823963c7547a9"), PublicKey(hex"0242a4ae0c5bef18048fbecf995094b74bfb0f7391418d71ed394784373f41e4f3"), PublicKey(hex"026ac9fcd64fb1aa1c491fc490634dc33da41d4a17b554e0adf1b32fee88ee9f28"))
val csvNodes = "0217eb8243c95f5a3b7d4c5682d10de354b7007eb59b6807ae407823963c7547a9, 0242a4ae0c5bef18048fbecf995094b74bfb0f7391418d71ed394784373f41e4f3, 026ac9fcd64fb1aa1c491fc490634dc33da41d4a17b554e0adf1b32fee88ee9f28"
val jsonNodes = serialization.write(expectedRoute)
val mockEclair = mock[Eclair]
val service = new MockService(mockEclair)
mockEclair.sendToRoute(any[Option[String]], any[List[PublicKey]], any[MilliSatoshi], any[ByteVector32], any[CltvExpiryDelta], any[Option[PaymentRequest]])(any[Timeout]) returns Future.successful(paymentUUID)
Post("/sendtoroute", FormData(Map("route" -> jsonNodes, "amountMsat" -> "1234", "paymentHash" -> ByteVector32.Zeroes.toHex, "finalCltvExpiry" -> "190", "externalId" -> externalId.toString))) ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
addHeader("Content-Type", "application/json") ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == OK)
assert(responseAs[String] == "\"" + rawUUID + "\"")
mockEclair.sendToRoute(Some(externalId), expectedRoute, 1234 msat, ByteVector32.Zeroes, CltvExpiryDelta(190), any[Option[PaymentRequest]])(any[Timeout]).wasCalled(once)
}
// this test uses CSV encoded route
Post("/sendtoroute", FormData(Map("route" -> csvNodes, "amountMsat" -> "1234", "paymentHash" -> ByteVector32.One.toHex, "finalCltvExpiry" -> "190"))) ~>
addCredentials(BasicHttpCredentials("", mockPassword)) ~>
addHeader("Content-Type", "application/json") ~>
HttpService.sealRoute(service.route) ~>
check {
assert(handled)
assert(status == OK)
assert(responseAs[String] == "\"" + rawUUID + "\"")
mockEclair.sendToRoute(None, expectedRoute, 1234 msat, ByteVector32.One, CltvExpiryDelta(190), any[Option[PaymentRequest]])(any[Timeout]).wasCalled(once)
}
}
private def matchTestJson(apiName: String, response: String) = {
val resource = getClass.getResourceAsStream(s"/api/$apiName")
val expectedResponse = Try(Source.fromInputStream(resource).mkString).getOrElse {
throw new IllegalArgumentException(s"Mock file for $apiName not found")
}
assert(response == expectedResponse, s"Test mock for $apiName did not match the expected response")
}
}