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:
commit
e78e091e62
18 changed files with 720 additions and 605 deletions
|
@ -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)
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 =>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
7
eclair-core/src/main/scala/kamon/trace/Span.scala
Normal file
7
eclair-core/src/main/scala/kamon/trace/Span.scala
Normal 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
|
||||
}
|
|
@ -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._
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
1
eclair-node/src/test/resources/api/networkstats
Normal file
1
eclair-node/src/test/resources/api/networkstats
Normal 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}}
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Reference in a new issue