From 48ad9b30e62a7990441a4c2d3e1fb37dd55b14a8 Mon Sep 17 00:00:00 2001 From: Bastien Teinturier <31281497+t-bast@users.noreply.github.com> Date: Fri, 31 Jan 2020 11:52:15 +0100 Subject: [PATCH] Trampoline/MPP API changes (#1297) Let a sender manually split a payment and specify a trampoline route. Fix two flaky tests where the order of payment parts could be different, resulting in a failed equality test. If we're relaying multiple HTLCs for the same payment_hash, we need to list all of those. The previous code only handled that when Trampoline was used. --- .../main/scala/fr/acinq/eclair/Eclair.scala | 33 ++-- .../eclair/db/sqlite/SqliteAuditDb.scala | 22 +-- .../payment/send/PaymentInitiator.scala | 119 ++++++++++++-- .../fr/acinq/eclair/EclairImplSpec.scala | 19 ++- .../acinq/eclair/db/SqliteAuditDbSpec.scala | 21 +-- .../eclair/integration/IntegrationSpec.scala | 147 +++++++++--------- .../eclair/payment/PaymentInitiatorSpec.scala | 49 ++++-- .../fr/acinq/eclair/api/JsonSerializers.scala | 1 - .../scala/fr/acinq/eclair/api/Service.scala | 18 +-- .../fr/acinq/eclair/api/ApiServiceSpec.scala | 23 +-- 10 files changed, 282 insertions(+), 170 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index 52e1eb5a1..6d38941f0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -33,7 +33,7 @@ import fr.acinq.eclair.io.{NodeURI, Peer} import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceivePayment import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, OutgoingChannels, UsableBalance} -import fr.acinq.eclair.payment.send.PaymentInitiator.{SendPaymentRequest, SendTrampolinePaymentRequest} +import fr.acinq.eclair.payment.send.PaymentInitiator.{SendPaymentRequest, SendPaymentToRouteRequest, SendPaymentToRouteResponse} import fr.acinq.eclair.router._ import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement} import scodec.bits.ByteVector @@ -88,13 +88,11 @@ trait Eclair { def send(externalId_opt: Option[String], recipientNodeId: PublicKey, amount: MilliSatoshi, paymentHash: ByteVector32, invoice_opt: Option[PaymentRequest] = None, maxAttempts_opt: Option[Int] = None, feeThresholdSat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None)(implicit timeout: Timeout): Future[UUID] - def sendToTrampoline(invoice: PaymentRequest, trampolineId: PublicKey, trampolineFees: MilliSatoshi, trampolineExpiryDelta: CltvExpiryDelta)(implicit timeout: Timeout): Future[UUID] - def sentInfo(id: Either[UUID, ByteVector32])(implicit timeout: Timeout): Future[Seq[OutgoingPayment]] def findRoute(targetNodeId: PublicKey, amount: MilliSatoshi, assistedRoutes: Seq[Seq[PaymentRequest.ExtraHop]] = Seq.empty)(implicit timeout: Timeout): Future[RouteResponse] - def sendToRoute(externalId_opt: Option[String], route: Seq[PublicKey], amount: MilliSatoshi, paymentHash: ByteVector32, finalCltvExpiryDelta: CltvExpiryDelta, invoice_opt: Option[PaymentRequest] = None)(implicit timeout: Timeout): Future[UUID] + def sendToRoute(amount: MilliSatoshi, recipientAmount_opt: Option[MilliSatoshi], externalId_opt: Option[String], parentId_opt: Option[UUID], invoice: PaymentRequest, finalCltvExpiryDelta: CltvExpiryDelta, route: Seq[PublicKey], trampolineSecret_opt: Option[ByteVector32] = None, trampolineFees_opt: Option[MilliSatoshi] = None, trampolineExpiryDelta_opt: Option[CltvExpiryDelta] = None, trampolineNodes_opt: Seq[PublicKey] = Nil)(implicit timeout: Timeout): Future[SendPaymentToRouteResponse] def audit(from_opt: Option[Long], to_opt: Option[Long])(implicit timeout: Timeout): Future[AuditResponse] @@ -214,10 +212,21 @@ class EclairImpl(appKit: Kit) extends Eclair { (appKit.router ? RouteRequest(appKit.nodeParams.nodeId, targetNodeId, amount, assistedRoutes)).mapTo[RouteResponse] } - override def sendToRoute(externalId_opt: Option[String], route: Seq[PublicKey], amount: MilliSatoshi, paymentHash: ByteVector32, finalCltvExpiryDelta: CltvExpiryDelta, invoice_opt: Option[PaymentRequest] = None)(implicit timeout: Timeout): Future[UUID] = { - externalId_opt match { - case Some(externalId) if externalId.length > externalIdMaxLength => Future.failed(new IllegalArgumentException("externalId is too long: cannot exceed 66 characters")) - case _ => (appKit.paymentInitiator ? SendPaymentRequest(amount, paymentHash, route.last, 1, finalCltvExpiryDelta, invoice_opt, externalId_opt, route)).mapTo[UUID] + override def sendToRoute(amount: MilliSatoshi, recipientAmount_opt: Option[MilliSatoshi], externalId_opt: Option[String], parentId_opt: Option[UUID], invoice: PaymentRequest, finalCltvExpiryDelta: CltvExpiryDelta, route: Seq[PublicKey], trampolineSecret_opt: Option[ByteVector32], trampolineFees_opt: Option[MilliSatoshi], trampolineExpiryDelta_opt: Option[CltvExpiryDelta], trampolineNodes_opt: Seq[PublicKey])(implicit timeout: Timeout): Future[SendPaymentToRouteResponse] = { + val recipientAmount = recipientAmount_opt.getOrElse(invoice.amount.getOrElse(amount)) + val sendPayment = SendPaymentToRouteRequest(amount, recipientAmount, externalId_opt, parentId_opt, invoice, finalCltvExpiryDelta, route, trampolineSecret_opt, trampolineFees_opt.getOrElse(0 msat), trampolineExpiryDelta_opt.getOrElse(CltvExpiryDelta(0)), trampolineNodes_opt) + if (invoice.isExpired) { + Future.failed(new IllegalArgumentException("invoice has expired")) + } else if (route.isEmpty) { + Future.failed(new IllegalArgumentException("missing payment route")) + } else if (externalId_opt.exists(_.length > externalIdMaxLength)) { + Future.failed(new IllegalArgumentException(s"externalId is too long: cannot exceed $externalIdMaxLength characters")) + } else if (trampolineNodes_opt.nonEmpty && (trampolineFees_opt.isEmpty || trampolineExpiryDelta_opt.isEmpty)) { + Future.failed(new IllegalArgumentException("trampoline payments must specify a trampoline fee and cltv delta")) + } else if (trampolineNodes_opt.nonEmpty && trampolineNodes_opt.length != 2) { + Future.failed(new IllegalArgumentException("trampoline payments currently only support paying a trampoline node via a single other trampoline node")) + } else { + (appKit.paymentInitiator ? sendPayment).mapTo[SendPaymentToRouteResponse] } } @@ -230,7 +239,7 @@ class EclairImpl(appKit: Kit) extends Eclair { ) externalId_opt match { - case Some(externalId) if externalId.length > externalIdMaxLength => Future.failed(new IllegalArgumentException("externalId is too long: cannot exceed 66 characters")) + case Some(externalId) if externalId.length > externalIdMaxLength => Future.failed(new IllegalArgumentException(s"externalId is too long: cannot exceed $externalIdMaxLength characters")) case _ => invoice_opt match { case Some(invoice) if invoice.isExpired => Future.failed(new IllegalArgumentException("invoice has expired")) case Some(invoice) => @@ -246,12 +255,6 @@ class EclairImpl(appKit: Kit) extends Eclair { } } - override def sendToTrampoline(invoice: PaymentRequest, trampolineId: PublicKey, trampolineFees: MilliSatoshi, trampolineExpiryDelta: CltvExpiryDelta)(implicit timeout: Timeout): Future[UUID] = { - val defaultRouteParams = Router.getDefaultRouteParams(appKit.nodeParams.routerConf) - val sendPayment = SendTrampolinePaymentRequest(invoice.amount.get, invoice, trampolineId, Seq((trampolineFees, trampolineExpiryDelta)), invoice.minFinalCltvExpiryDelta.getOrElse(Channel.MIN_CLTV_EXPIRY_DELTA), Some(defaultRouteParams)) - (appKit.paymentInitiator ? sendPayment).mapTo[UUID] - } - override def sentInfo(id: Either[UUID, ByteVector32])(implicit timeout: Timeout): Future[Seq[OutgoingPayment]] = Future { id match { case Left(uuid) => appKit.nodeParams.db.payments.listOutgoingPayments(uuid) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteAuditDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteAuditDb.scala index f6aaf5de2..42600fcb2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteAuditDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteAuditDb.scala @@ -278,17 +278,19 @@ class SqliteAuditDb(sqlite: Connection) extends AuditDb with Logging { rs.getLong("timestamp")) relayedByHash = relayedByHash + (paymentHash -> (relayedByHash.getOrElse(paymentHash, Nil) :+ part)) } - relayedByHash.map { - case (paymentHash, parts) => parts.head.relayType match { - case "channel" => parts.foldLeft(ChannelPaymentRelayed(0 msat, 0 msat, paymentHash, ByteVector32.Zeroes, ByteVector32.Zeroes, parts.head.timestamp)) { - case (e, part) if part.direction == "IN" => e.copy(amountIn = part.amount, fromChannelId = part.channelId) - case (e, part) if part.direction == "OUT" => e.copy(amountOut = part.amount, toChannelId = part.channelId) + relayedByHash.flatMap { + case (paymentHash, parts) => + // We may have been routing multiple payments for the same payment_hash (MPP) in both cases (trampoline and channel). + // NB: we may link the wrong in-out parts, but the overall sum will be correct: we sort by amounts to minimize the risk of mismatch. + val incoming = parts.filter(_.direction == "IN").map(p => PaymentRelayed.Part(p.amount, p.channelId)).sortBy(_.amount) + val outgoing = parts.filter(_.direction == "OUT").map(p => PaymentRelayed.Part(p.amount, p.channelId)).sortBy(_.amount) + parts.headOption match { + case Some(RelayedPart(_, _, _, "channel", timestamp)) => incoming.zip(outgoing).map { + case (in, out) => ChannelPaymentRelayed(in.amount, out.amount, paymentHash, in.channelId, out.channelId, timestamp) + } + case Some(RelayedPart(_, _, _, "trampoline", timestamp)) => TrampolinePaymentRelayed(paymentHash, incoming, outgoing, timestamp) :: Nil + case _ => Nil } - case "trampoline" => parts.foldLeft(TrampolinePaymentRelayed(paymentHash, Nil, Nil, parts.head.timestamp)) { - case (e, part) if part.direction == "IN" => e.copy(incoming = e.incoming :+ PaymentRelayed.Part(part.amount, part.channelId)) - case (e, part) if part.direction == "OUT" => e.copy(outgoing = e.outgoing :+ PaymentRelayed.Part(part.amount, part.channelId)) - } - } }.toSeq.sortBy(_.timestamp) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentInitiator.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentInitiator.scala index 88fc3922e..f582a837f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentInitiator.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentInitiator.scala @@ -29,8 +29,8 @@ import fr.acinq.eclair.payment.send.MultiPartPaymentLifecycle.SendMultiPartPayme import fr.acinq.eclair.payment.send.PaymentLifecycle.{SendPayment, SendPaymentToRoute} import fr.acinq.eclair.router.{ChannelHop, Hop, NodeHop, RouteParams} import fr.acinq.eclair.wire.Onion.FinalLegacyPayload -import fr.acinq.eclair.wire.{Onion, OnionTlv, TrampolineExpiryTooSoon, TrampolineFeeInsufficient} -import fr.acinq.eclair.{CltvExpiryDelta, Features, LongToBtcAmount, MilliSatoshi, NodeParams, randomBytes32} +import fr.acinq.eclair.wire._ +import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, LongToBtcAmount, MilliSatoshi, NodeParams, randomBytes32} /** * Created by PM on 29/08/2016. @@ -52,19 +52,14 @@ class PaymentInitiator(nodeParams: NodeParams, router: ActorRef, relayer: ActorR sender ! PaymentFailed(paymentId, r.paymentHash, LocalFailure(InvalidInvoice(s"unknown invoice features (${invoice.features})")) :: Nil) case Some(invoice) if invoice.features.allowMultiPart && Features.hasFeature(nodeParams.features, Features.BasicMultiPartPayment) => invoice.paymentSecret match { - case Some(paymentSecret) => r.predefinedRoute match { - case Nil => spawnMultiPartPaymentFsm(paymentCfg) forward SendMultiPartPayment(paymentSecret, r.recipientNodeId, r.recipientAmount, finalExpiry, r.maxAttempts, r.assistedRoutes, r.routeParams) - case hops => spawnPaymentFsm(paymentCfg) forward SendPaymentToRoute(hops, Onion.createMultiPartPayload(r.recipientAmount, invoice.amount.getOrElse(r.recipientAmount), finalExpiry, paymentSecret)) - } - case None => sender ! PaymentFailed(paymentId, r.paymentHash, LocalFailure(InvalidInvoice("multi-part invoice is missing a payment secret")) :: Nil) + case Some(paymentSecret) => + spawnMultiPartPaymentFsm(paymentCfg) forward SendMultiPartPayment(paymentSecret, r.recipientNodeId, r.recipientAmount, finalExpiry, r.maxAttempts, r.assistedRoutes, r.routeParams) + case None => + sender ! PaymentFailed(paymentId, r.paymentHash, LocalFailure(InvalidInvoice("multi-part invoice is missing a payment secret")) :: Nil) } 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.recipientNodeId, FinalLegacyPayload(r.recipientAmount, finalExpiry), r.maxAttempts, r.assistedRoutes, r.routeParams) - case hops => payFsm forward SendPaymentToRoute(hops, FinalLegacyPayload(r.recipientAmount, finalExpiry)) - } + spawnPaymentFsm(paymentCfg) forward SendPayment(r.recipientNodeId, FinalLegacyPayload(r.recipientAmount, finalExpiry), r.maxAttempts, r.assistedRoutes, r.routeParams) } case r: SendTrampolinePaymentRequest => @@ -101,18 +96,42 @@ class PaymentInitiator(nodeParams: NodeParams, router: ActorRef, relayer: ActorR context.system.eventStream.publish(ps) context become main(pending - ps.id) }) + + case r: SendPaymentToRouteRequest => + val paymentId = UUID.randomUUID() + val parentPaymentId = r.parentId.getOrElse(UUID.randomUUID()) + val finalExpiry = r.finalExpiry(nodeParams.currentBlockHeight) + val additionalHops = r.trampolineNodes.sliding(2).map(hop => NodeHop(hop.head, hop(1), CltvExpiryDelta(0), 0 msat)).toSeq + val paymentCfg = SendPaymentConfig(paymentId, parentPaymentId, r.externalId, r.paymentHash, r.recipientAmount, r.recipientNodeId, Upstream.Local(paymentId), Some(r.paymentRequest), storeInDb = true, publishEvent = true, additionalHops) + val payFsm = spawnPaymentFsm(paymentCfg) + r.trampolineNodes match { + case trampoline :: recipient :: Nil => + log.info(s"sending trampoline payment to $recipient with trampoline=$trampoline, trampoline fees=${r.trampolineFees}, expiry delta=${r.trampolineExpiryDelta}") + // We generate a random secret for the payment to the first trampoline node. + val trampolineSecret = r.trampolineSecret.getOrElse(randomBytes32) + sender ! SendPaymentToRouteResponse(paymentId, parentPaymentId, Some(trampolineSecret)) + val (trampolineAmount, trampolineExpiry, trampolineOnion) = buildTrampolinePayment(SendTrampolinePaymentRequest(r.recipientAmount, r.paymentRequest, trampoline, Seq((r.trampolineFees, r.trampolineExpiryDelta)), r.finalExpiryDelta), r.trampolineFees, r.trampolineExpiryDelta) + payFsm forward SendPaymentToRoute(r.route, Onion.createMultiPartPayload(r.amount, trampolineAmount, trampolineExpiry, trampolineSecret, Seq(OnionTlv.TrampolineOnion(trampolineOnion)))) + case Nil => + sender ! SendPaymentToRouteResponse(paymentId, parentPaymentId, None) + r.paymentRequest.paymentSecret match { + case Some(paymentSecret) => payFsm forward SendPaymentToRoute(r.route, Onion.createMultiPartPayload(r.amount, r.recipientAmount, finalExpiry, paymentSecret)) + case None => payFsm forward SendPaymentToRoute(r.route, FinalLegacyPayload(r.recipientAmount, finalExpiry)) + } + case _ => + sender ! PaymentFailed(paymentId, r.paymentHash, LocalFailure(new IllegalArgumentException(s"unsupported number of trampoline nodes: ${r.trampolineNodes}")) :: Nil) + } } def spawnPaymentFsm(paymentCfg: SendPaymentConfig): ActorRef = context.actorOf(PaymentLifecycle.props(nodeParams, paymentCfg, router, register)) def spawnMultiPartPaymentFsm(paymentCfg: SendPaymentConfig): ActorRef = context.actorOf(MultiPartPaymentLifecycle.props(nodeParams, paymentCfg, relayer, router, register)) - private def sendTrampolinePayment(paymentId: UUID, r: SendTrampolinePaymentRequest, trampolineFees: MilliSatoshi, trampolineExpiryDelta: CltvExpiryDelta): Unit = { + private def buildTrampolinePayment(r: SendTrampolinePaymentRequest, trampolineFees: MilliSatoshi, trampolineExpiryDelta: CltvExpiryDelta): (MilliSatoshi, CltvExpiry, OnionRoutingPacket) = { val trampolineRoute = Seq( NodeHop(nodeParams.nodeId, r.trampolineNodeId, nodeParams.expiryDeltaBlocks, 0 msat), NodeHop(r.trampolineNodeId, r.recipientNodeId, trampolineExpiryDelta, trampolineFees) // for now we only use a single trampoline hop ) - val paymentCfg = SendPaymentConfig(paymentId, paymentId, None, r.paymentHash, r.recipientAmount, r.recipientNodeId, Upstream.Local(paymentId), Some(r.paymentRequest), storeInDb = true, publishEvent = false, trampolineRoute.tail) val finalPayload = if (r.paymentRequest.features.allowMultiPart) { Onion.createMultiPartPayload(r.recipientAmount, r.recipientAmount, r.finalExpiry(nodeParams.currentBlockHeight), r.paymentRequest.paymentSecret.get) } else { @@ -124,9 +143,15 @@ class PaymentInitiator(nodeParams: NodeParams, router: ActorRef, relayer: ActorR } else { OutgoingPacket.buildTrampolineToLegacyPacket(r.paymentRequest, trampolineRoute, finalPayload) } + (trampolineAmount, trampolineExpiry, trampolineOnion.packet) + } + + private def sendTrampolinePayment(paymentId: UUID, r: SendTrampolinePaymentRequest, trampolineFees: MilliSatoshi, trampolineExpiryDelta: CltvExpiryDelta): Unit = { + val paymentCfg = SendPaymentConfig(paymentId, paymentId, None, r.paymentHash, r.recipientAmount, r.recipientNodeId, Upstream.Local(paymentId), Some(r.paymentRequest), storeInDb = true, publishEvent = false, Seq(NodeHop(r.trampolineNodeId, r.recipientNodeId, trampolineExpiryDelta, trampolineFees))) // We generate a random secret for this payment to avoid leaking the invoice secret to the first trampoline node. val trampolineSecret = randomBytes32 - spawnMultiPartPaymentFsm(paymentCfg) ! SendMultiPartPayment(trampolineSecret, r.trampolineNodeId, trampolineAmount, trampolineExpiry, 1, r.paymentRequest.routingInfo, r.routeParams, Seq(OnionTlv.TrampolineOnion(trampolineOnion.packet))) + val (trampolineAmount, trampolineExpiry, trampolineOnion) = buildTrampolinePayment(r, trampolineFees, trampolineExpiryDelta) + spawnMultiPartPaymentFsm(paymentCfg) ! SendMultiPartPayment(trampolineSecret, r.trampolineNodeId, trampolineAmount, trampolineExpiry, 1, r.paymentRequest.routingInfo, r.routeParams, Seq(OnionTlv.TrampolineOnion(trampolineOnion))) } } @@ -173,7 +198,6 @@ object PaymentInitiator { * @param finalExpiryDelta expiry delta for the final recipient. * @param paymentRequest (optional) Bolt 11 invoice. * @param externalId (optional) externally-controlled identifier (to reconcile between application DB and eclair DB). - * @param predefinedRoute (optional) route to use for the payment. * @param assistedRoutes (optional) routing hints (usually from a Bolt 11 invoice). * @param routeParams (optional) parameters to fine-tune the routing algorithm. */ @@ -184,13 +208,74 @@ object PaymentInitiator { finalExpiryDelta: CltvExpiryDelta = Channel.MIN_CLTV_EXPIRY_DELTA, paymentRequest: Option[PaymentRequest] = None, externalId: Option[String] = None, - predefinedRoute: Seq[PublicKey] = Nil, assistedRoutes: Seq[Seq[ExtraHop]] = Nil, routeParams: Option[RouteParams] = None) { // We add one block in order to not have our htlcs fail when a new block has just been found. def finalExpiry(currentBlockHeight: Long) = finalExpiryDelta.toCltvExpiry(currentBlockHeight + 1) } + /** + * The sender can skip the routing algorithm by specifying the route to use. + * When combining with MPP and Trampoline, extra-care must be taken to make sure payments are correctly grouped: only + * amount, route and trampolineNodes should be changing. + * + * Example 1: MPP containing two HTLCs for a 600 msat invoice: + * SendPaymentToRouteRequest(200 msat, 600 msat, None, parentId, invoice, CltvExpiryDelta(9), Seq(alice, bob, dave), None, 0 msat, CltvExpiryDelta(0), Nil) + * SendPaymentToRouteRequest(400 msat, 600 msat, None, parentId, invoice, CltvExpiryDelta(9), Seq(alice, carol, dave), None, 0 msat, CltvExpiryDelta(0), Nil) + * + * Example 2: Trampoline with MPP for a 600 msat invoice and 100 msat trampoline fees: + * SendPaymentToRouteRequest(250 msat, 600 msat, None, parentId, invoice, CltvExpiryDelta(9), Seq(alice, bob, dave), secret, 100 msat, CltvExpiryDelta(144), Seq(dave, peter)) + * SendPaymentToRouteRequest(450 msat, 600 msat, None, parentId, invoice, CltvExpiryDelta(9), Seq(alice, carol, dave), secret, 100 msat, CltvExpiryDelta(144), Seq(dave, peter)) + * + * @param amount amount that should be received by the last node in the route (should take trampoline + * fees into account). + * @param recipientAmount amount that should be received by the final recipient (usually from a Bolt 11 invoice). + * This amount may be split between multiple requests if using MPP. + * @param externalId (optional) externally-controlled identifier (to reconcile between application DB and eclair DB). + * @param parentId id of the whole payment. When manually sending a multi-part payment, you need to make + * sure all partial payments use the same parentId. If not provided, a random parentId will + * be generated that can be used for the remaining partial payments. + * @param paymentRequest Bolt 11 invoice. + * @param finalExpiryDelta expiry delta for the final recipient. + * @param route route to use to reach either the final recipient or the first trampoline node. + * @param trampolineSecret if trampoline is used, this is a secret to protect the payment to the first trampoline + * node against probing. When manually sending a multi-part payment, you need to make sure + * all partial payments use the same trampolineSecret. + * @param trampolineFees if trampoline is used, fees for the first trampoline node. This value must be the same + * for all partial payments in the set. + * @param trampolineExpiryDelta if trampoline is used, expiry delta for the first trampoline node. This value must be + * the same for all partial payments in the set. + * @param trampolineNodes if trampoline is used, list of trampoline nodes to use (we currently support only a + * single trampoline node). + */ + case class SendPaymentToRouteRequest(amount: MilliSatoshi, + recipientAmount: MilliSatoshi, + externalId: Option[String], + parentId: Option[UUID], + paymentRequest: PaymentRequest, + finalExpiryDelta: CltvExpiryDelta = Channel.MIN_CLTV_EXPIRY_DELTA, + route: Seq[PublicKey], + trampolineSecret: Option[ByteVector32], + trampolineFees: MilliSatoshi, + trampolineExpiryDelta: CltvExpiryDelta, + trampolineNodes: Seq[PublicKey]) { + val recipientNodeId = paymentRequest.nodeId + val paymentHash = paymentRequest.paymentHash + + // We add one block in order to not have our htlcs fail when a new block has just been found. + def finalExpiry(currentBlockHeight: Long) = finalExpiryDelta.toCltvExpiry(currentBlockHeight + 1) + } + + /** + * @param paymentId id of the outgoing payment (mapped to a single outgoing HTLC). + * @param parentId id of the whole payment. When manually sending a multi-part payment, you need to make sure + * all partial payments use the same parentId. + * @param trampolineSecret if trampoline is used, this is a secret to protect the payment to the first trampoline node + * against probing. When manually sending a multi-part payment, you need to make sure all + * partial payments use the same trampolineSecret. + */ + case class SendPaymentToRouteResponse(paymentId: UUID, parentId: UUID, trampolineSecret: Option[ByteVector32]) + /** * Configuration for an instance of a payment state machine. * diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala index 9be252450..56bb63240 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -16,6 +16,8 @@ package fr.acinq.eclair +import java.util.UUID + import akka.actor.ActorSystem import akka.testkit.{TestKit, TestProbe} import akka.util.Timeout @@ -30,7 +32,7 @@ import fr.acinq.eclair.payment.PaymentRequest import fr.acinq.eclair.payment.PaymentRequest.ExtraHop 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.payment.send.PaymentInitiator.{SendPaymentRequest, SendPaymentToRouteRequest} import fr.acinq.eclair.router.RouteCalculationSpec.makeUpdate import fr.acinq.eclair.router.{Announcements, PublicChannel, Router, GetNetworkStats, NetworkStats, Stats} import org.mockito.Mockito @@ -313,18 +315,15 @@ class EclairImplSpec extends TestKit(ActorSystem("test")) with fixture.FunSuiteL test("sendtoroute should pass the parameters correctly") { f => import f._ - val route = Seq(PublicKey(hex"030bb6a5e0c6b203c7e2180fb78c7ba4bdce46126761d8201b91ddac089cdecc87")) val eclair = new EclairImpl(kit) + val route = Seq(randomKey.publicKey) + val trampolines = Seq(randomKey.publicKey, randomKey.publicKey) + val parentId = UUID.randomUUID() + val secret = randomBytes32 val pr = PaymentRequest(Block.LivenetGenesisBlock.hash, Some(1234 msat), ByteVector32.One, randomKey, "Some invoice") - eclair.sendToRoute(Some("42"), route, 1234 msat, ByteVector32.One, CltvExpiryDelta(123), Some(pr)) + eclair.sendToRoute(1000 msat, Some(1200 msat), Some("42"), Some(parentId), pr, CltvExpiryDelta(123), route, Some(secret), Some(100 msat), Some(CltvExpiryDelta(144)), trampolines) - val send = paymentInitiator.expectMsgType[SendPaymentRequest] - assert(send.externalId === Some("42")) - assert(send.predefinedRoute === route) - assert(send.recipientAmount === 1234.msat) - assert(send.finalExpiryDelta === CltvExpiryDelta(123)) - assert(send.paymentHash === ByteVector32.One) - assert(send.paymentRequest === Some(pr)) + paymentInitiator.expectMsg(SendPaymentToRouteRequest(1000 msat, 1200 msat, Some("42"), Some(parentId), pr, CltvExpiryDelta(123), route, Some(secret), 100 msat, CltvExpiryDelta(144), trampolines)) } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/SqliteAuditDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/SqliteAuditDbSpec.scala index 901c35266..7bb05c316 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/db/SqliteAuditDbSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/SqliteAuditDbSpec.scala @@ -61,7 +61,10 @@ class SqliteAuditDbSpec extends FunSuite { val e8 = ChannelLifecycleEvent(randomBytes32, randomKey.publicKey, 456123000 sat, isFunder = true, isPrivate = false, "mutual") val e9 = ChannelErrorOccurred(null, randomBytes32, randomKey.publicKey, null, LocalError(new RuntimeException("oops")), isFatal = true) val e10 = ChannelErrorOccurred(null, randomBytes32, randomKey.publicKey, null, RemoteError(wire.Error(randomBytes32, "remote oops")), isFatal = true) - val e11 = TrampolinePaymentRelayed(randomBytes32, Seq(PaymentRelayed.Part(20000 msat, randomBytes32), PaymentRelayed.Part(22000 msat, randomBytes32)), Seq(PaymentRelayed.Part(20000 msat, randomBytes32), PaymentRelayed.Part(10000 msat, randomBytes32), PaymentRelayed.Part(10000 msat, randomBytes32))) + val e11 = TrampolinePaymentRelayed(randomBytes32, Seq(PaymentRelayed.Part(20000 msat, randomBytes32), PaymentRelayed.Part(22000 msat, randomBytes32)), Seq(PaymentRelayed.Part(10000 msat, randomBytes32), PaymentRelayed.Part(12000 msat, randomBytes32), PaymentRelayed.Part(15000 msat, randomBytes32))) + val multiPartPaymentHash = randomBytes32 + val e12 = ChannelPaymentRelayed(13000 msat, 11000 msat, multiPartPaymentHash, randomBytes32, randomBytes32) + val e13 = ChannelPaymentRelayed(15000 msat, 12500 msat, multiPartPaymentHash, randomBytes32, randomBytes32) db.add(e1) db.add(e2) @@ -74,11 +77,13 @@ class SqliteAuditDbSpec extends FunSuite { db.add(e9) db.add(e10) db.add(e11) + db.add(e12) + db.add(e13) assert(db.listSent(from = 0L, to = (Platform.currentTime.milliseconds + 15.minute).toMillis).toSet === Set(e1, e5, e6)) assert(db.listSent(from = 100000L, to = (Platform.currentTime.milliseconds + 1.minute).toMillis).toList === List(e1)) assert(db.listReceived(from = 0L, to = (Platform.currentTime.milliseconds + 1.minute).toMillis).toList === List(e2)) - assert(db.listRelayed(from = 0L, to = (Platform.currentTime.milliseconds + 1.minute).toMillis).toList === List(e3, e11)) + assert(db.listRelayed(from = 0L, to = (Platform.currentTime.milliseconds + 1.minute).toMillis).toList === List(e3, e11, e12, e13)) assert(db.listNetworkFees(from = 0L, to = (Platform.currentTime.milliseconds + 1.minute).toMillis).size === 1) assert(db.listNetworkFees(from = 0L, to = (Platform.currentTime.milliseconds + 1.minute).toMillis).head.txType === "mutual") } @@ -343,7 +348,7 @@ class SqliteAuditDbSpec extends FunSuite { PaymentSent.PartialPayment(UUID.randomUUID(), 500 msat, 10 msat, randomBytes32, None, 160), PaymentSent.PartialPayment(UUID.randomUUID(), 600 msat, 5 msat, randomBytes32, None, 165) )) - val relayed3 = TrampolinePaymentRelayed(randomBytes32, Seq(PaymentRelayed.Part(500 msat, randomBytes32), PaymentRelayed.Part(450 msat, randomBytes32)), Seq(PaymentRelayed.Part(800 msat, randomBytes32)), 150) + val relayed3 = TrampolinePaymentRelayed(randomBytes32, Seq(PaymentRelayed.Part(450 msat, randomBytes32), PaymentRelayed.Part(500 msat, randomBytes32)), Seq(PaymentRelayed.Part(800 msat, randomBytes32)), 150) postMigrationDb.add(ps2) assert(postMigrationDb.listSent(155, 200) === Seq(ps2)) @@ -351,7 +356,7 @@ class SqliteAuditDbSpec extends FunSuite { assert(postMigrationDb.listRelayed(100, 160) === Seq(relayed1, relayed2, relayed3)) } - test("fails if the DB contains invalid values") { + test("ignore invalid values in the DB") { val sqlite = TestConstants.sqliteInMemory() val db = new SqliteAuditDb(sqlite) @@ -365,8 +370,6 @@ class SqliteAuditDbSpec extends FunSuite { statement.executeUpdate() } - assertThrows[MatchError](db.listRelayed(5, 15)) - using(sqlite.prepareStatement("INSERT INTO relayed (payment_hash, amount_msat, channel_id, direction, relay_type, timestamp) VALUES (?, ?, ?, ?, ?, ?)")) { statement => statement.setBytes(1, randomBytes32.toArray) statement.setLong(2, 51) @@ -377,8 +380,6 @@ class SqliteAuditDbSpec extends FunSuite { statement.executeUpdate() } - assertThrows[MatchError](db.listRelayed(15, 25)) - val paymentHash = randomBytes32 val channelId = randomBytes32 @@ -386,13 +387,13 @@ class SqliteAuditDbSpec extends FunSuite { statement.setBytes(1, paymentHash.toArray) statement.setLong(2, 65) statement.setBytes(3, channelId.toArray) - statement.setString(4, "IN") + statement.setString(4, "IN") // missing a corresponding OUT statement.setString(5, "channel") statement.setLong(6, 30) statement.executeUpdate() } - assert(db.listRelayed(25, 35) === Seq(ChannelPaymentRelayed(65 msat, 0 msat, paymentHash, channelId, ByteVector32.Zeroes, 30))) + assert(db.listRelayed(0, 40) === Nil) } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala index ee1b7e74d..c3e3ea8ed 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala @@ -451,24 +451,26 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with BitcoindService sender.send(nodes("B").paymentInitiator, SendPaymentRequest(amount, pr.paymentHash, nodes("D").nodeParams.nodeId, 5, paymentRequest = Some(pr))) val paymentId = sender.expectMsgType[UUID](30 seconds) val paymentSent = sender.expectMsgType[PaymentSent](30 seconds) - assert(paymentSent.id === paymentId) - assert(paymentSent.paymentHash === pr.paymentHash) - assert(paymentSent.parts.length > 1) - assert(paymentSent.recipientNodeId === nodes("D").nodeParams.nodeId) - assert(paymentSent.recipientAmount === amount) - assert(paymentSent.feesPaid > 0.msat) - assert(paymentSent.parts.forall(p => p.id != paymentSent.id)) - assert(paymentSent.parts.forall(p => p.route.isDefined)) + assert(paymentSent.id === paymentId, paymentSent) + assert(paymentSent.paymentHash === pr.paymentHash, paymentSent) + assert(paymentSent.parts.length > 1, paymentSent) + assert(paymentSent.recipientNodeId === nodes("D").nodeParams.nodeId, paymentSent) + assert(paymentSent.recipientAmount === amount, paymentSent) + assert(paymentSent.feesPaid > 0.msat, paymentSent) + assert(paymentSent.parts.forall(p => p.id != paymentSent.id), paymentSent) + assert(paymentSent.parts.forall(p => p.route.isDefined), paymentSent) val paymentParts = nodes("B").nodeParams.db.payments.listOutgoingPayments(paymentId).filter(_.status.isInstanceOf[OutgoingPaymentStatus.Succeeded]) - assert(paymentParts.length == paymentSent.parts.length) - assert(paymentParts.map(_.amount).sum === amount) - assert(paymentParts.forall(p => p.parentId == paymentId)) - assert(paymentParts.forall(p => p.parentId != p.id)) - assert(paymentParts.forall(p => p.status.asInstanceOf[OutgoingPaymentStatus.Succeeded].feesPaid > 0.msat)) + assert(paymentParts.length == paymentSent.parts.length, paymentParts) + assert(paymentParts.map(_.amount).sum === amount, paymentParts) + assert(paymentParts.forall(p => p.parentId == paymentId), paymentParts) + assert(paymentParts.forall(p => p.parentId != p.id), paymentParts) + assert(paymentParts.forall(p => p.status.asInstanceOf[OutgoingPaymentStatus.Succeeded].feesPaid > 0.msat), paymentParts) awaitCond(nodes("B").nodeParams.db.audit.listSent(start, Platform.currentTime).nonEmpty) - assert(nodes("B").nodeParams.db.audit.listSent(start, Platform.currentTime) === Seq(paymentSent.copy(parts = paymentSent.parts.map(_.copy(route = None))))) + val sent = nodes("B").nodeParams.db.audit.listSent(start, Platform.currentTime) + assert(sent.length === 1, sent) + assert(sent.head.copy(parts = sent.head.parts.sortBy(_.timestamp)) === paymentSent.copy(parts = paymentSent.parts.map(_.copy(route = None)).sortBy(_.timestamp)), sent) awaitCond(nodes("D").nodeParams.db.payments.getIncomingPayment(pr.paymentHash).exists(_.status.isInstanceOf[IncomingPaymentStatus.Received])) val Some(IncomingPayment(_, _, _, _, IncomingPaymentStatus.Received(receivedAmount, _))) = nodes("D").nodeParams.db.payments.getIncomingPayment(pr.paymentHash) @@ -492,9 +494,9 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with BitcoindService sender.send(nodes("B").paymentInitiator, SendPaymentRequest(amount, pr.paymentHash, nodes("D").nodeParams.nodeId, 1, paymentRequest = Some(pr))) val paymentId = sender.expectMsgType[UUID](30 seconds) val paymentFailed = sender.expectMsgType[PaymentFailed](45 seconds) - assert(paymentFailed.id === paymentId) - assert(paymentFailed.paymentHash === pr.paymentHash) - assert(paymentFailed.failures.length > 1) + assert(paymentFailed.id === paymentId, paymentFailed) + assert(paymentFailed.paymentHash === pr.paymentHash, paymentFailed) + assert(paymentFailed.failures.length > 1, paymentFailed) assert(nodes("D").nodeParams.db.payments.getIncomingPayment(pr.paymentHash).get.status === IncomingPaymentStatus.Pending) @@ -515,17 +517,17 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with BitcoindService sender.send(nodes("D").paymentInitiator, SendPaymentRequest(amount, pr.paymentHash, nodes("C").nodeParams.nodeId, 3, paymentRequest = Some(pr))) val paymentId = sender.expectMsgType[UUID](30 seconds) val paymentSent = sender.expectMsgType[PaymentSent](30 seconds) - assert(paymentSent.id === paymentId) - assert(paymentSent.paymentHash === pr.paymentHash) - assert(paymentSent.parts.length > 1) - assert(paymentSent.recipientAmount === amount) - assert(paymentSent.feesPaid === 0.msat) // no fees when using direct channels + assert(paymentSent.id === paymentId, paymentSent) + assert(paymentSent.paymentHash === pr.paymentHash, paymentSent) + assert(paymentSent.parts.length > 1, paymentSent) + assert(paymentSent.recipientAmount === amount, paymentSent) + assert(paymentSent.feesPaid === 0.msat, paymentSent) // no fees when using direct channels val paymentParts = nodes("D").nodeParams.db.payments.listOutgoingPayments(paymentId).filter(_.status.isInstanceOf[OutgoingPaymentStatus.Succeeded]) - assert(paymentParts.map(_.amount).sum === amount) - assert(paymentParts.forall(p => p.parentId == paymentId)) - assert(paymentParts.forall(p => p.parentId != p.id)) - assert(paymentParts.forall(p => p.status.asInstanceOf[OutgoingPaymentStatus.Succeeded].feesPaid == 0.msat)) + assert(paymentParts.map(_.amount).sum === amount, paymentParts) + assert(paymentParts.forall(p => p.parentId == paymentId), paymentParts) + assert(paymentParts.forall(p => p.parentId != p.id), paymentParts) + assert(paymentParts.forall(p => p.status.asInstanceOf[OutgoingPaymentStatus.Succeeded].feesPaid == 0.msat), paymentParts) awaitCond(nodes("C").nodeParams.db.payments.getIncomingPayment(pr.paymentHash).exists(_.status.isInstanceOf[IncomingPaymentStatus.Received])) val Some(IncomingPayment(_, _, _, _, IncomingPaymentStatus.Received(receivedAmount, _))) = nodes("C").nodeParams.db.payments.getIncomingPayment(pr.paymentHash) @@ -547,10 +549,11 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with BitcoindService sender.send(nodes("D").paymentInitiator, SendPaymentRequest(amount, pr.paymentHash, nodes("C").nodeParams.nodeId, 1, paymentRequest = Some(pr))) val paymentId = sender.expectMsgType[UUID](30 seconds) val paymentFailed = sender.expectMsgType[PaymentFailed](30 seconds) - assert(paymentFailed.id === paymentId) - assert(paymentFailed.paymentHash === pr.paymentHash) + assert(paymentFailed.id === paymentId, paymentFailed) + assert(paymentFailed.paymentHash === pr.paymentHash, paymentFailed) - assert(nodes("C").nodeParams.db.payments.getIncomingPayment(pr.paymentHash).get.status === IncomingPaymentStatus.Pending) + val incoming = nodes("C").nodeParams.db.payments.getIncomingPayment(pr.paymentHash) + assert(incoming.get.status === IncomingPaymentStatus.Pending, incoming) sender.send(nodes("D").relayer, GetOutgoingChannels()) val canSend2 = sender.expectMsgType[OutgoingChannels].channels.map(_.commitments.availableBalanceForSend).sum @@ -573,12 +576,12 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with BitcoindService sender.send(nodes("B").paymentInitiator, payment) val paymentId = sender.expectMsgType[UUID](30 seconds) val paymentSent = sender.expectMsgType[PaymentSent](30 seconds) - assert(paymentSent.id === paymentId) - assert(paymentSent.paymentHash === pr.paymentHash) - assert(paymentSent.recipientNodeId === nodes("F3").nodeParams.nodeId) - assert(paymentSent.recipientAmount === amount) - assert(paymentSent.feesPaid === 1000000.msat) - assert(paymentSent.nonTrampolineFees === 0.msat) + assert(paymentSent.id === paymentId, paymentSent) + assert(paymentSent.paymentHash === pr.paymentHash, paymentSent) + assert(paymentSent.recipientNodeId === nodes("F3").nodeParams.nodeId, paymentSent) + assert(paymentSent.recipientAmount === amount, paymentSent) + assert(paymentSent.feesPaid === 1000000.msat, paymentSent) + assert(paymentSent.nonTrampolineFees === 0.msat, paymentSent) awaitCond(nodes("F3").nodeParams.db.payments.getIncomingPayment(pr.paymentHash).exists(_.status.isInstanceOf[IncomingPaymentStatus.Received])) val Some(IncomingPayment(_, _, _, _, IncomingPaymentStatus.Received(receivedAmount, _))) = nodes("F3").nodeParams.db.payments.getIncomingPayment(pr.paymentHash) @@ -586,15 +589,15 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with BitcoindService awaitCond(nodes("G").nodeParams.db.audit.listRelayed(start, Platform.currentTime).exists(_.paymentHash == pr.paymentHash)) val relayed = nodes("G").nodeParams.db.audit.listRelayed(start, Platform.currentTime).filter(_.paymentHash == pr.paymentHash).head - assert(relayed.amountIn - relayed.amountOut > 0.msat) - assert(relayed.amountIn - relayed.amountOut < 1000000.msat) + assert(relayed.amountIn - relayed.amountOut > 0.msat, relayed) + assert(relayed.amountIn - relayed.amountOut < 1000000.msat, relayed) val outgoingSuccess = nodes("B").nodeParams.db.payments.listOutgoingPayments(paymentId).filter(p => p.status.isInstanceOf[OutgoingPaymentStatus.Succeeded]) - outgoingSuccess.foreach { case OutgoingPayment(_, _, _, _, _, _, _, recipientNodeId, _, _, OutgoingPaymentStatus.Succeeded(_, _, route, _)) => - assert(recipientNodeId === nodes("F3").nodeParams.nodeId) - assert(route.lastOption === Some(HopSummary(nodes("G").nodeParams.nodeId, nodes("F3").nodeParams.nodeId))) + outgoingSuccess.foreach { case p@OutgoingPayment(_, _, _, _, _, _, _, recipientNodeId, _, _, OutgoingPaymentStatus.Succeeded(_, _, route, _)) => + assert(recipientNodeId === nodes("F3").nodeParams.nodeId, p) + assert(route.lastOption === Some(HopSummary(nodes("G").nodeParams.nodeId, nodes("F3").nodeParams.nodeId)), p) } - assert(outgoingSuccess.map(_.amount).sum === amount + 1000000.msat) + assert(outgoingSuccess.map(_.amount).sum === amount + 1000000.msat, outgoingSuccess) } test("send a trampoline payment D->B (via trampoline C)") { @@ -610,11 +613,11 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with BitcoindService sender.send(nodes("D").paymentInitiator, payment) val paymentId = sender.expectMsgType[UUID](30 seconds) val paymentSent = sender.expectMsgType[PaymentSent](30 seconds) - assert(paymentSent.id === paymentId) - assert(paymentSent.paymentHash === pr.paymentHash) - assert(paymentSent.recipientAmount === amount) - assert(paymentSent.feesPaid === 300000.msat) - assert(paymentSent.nonTrampolineFees === 0.msat) + assert(paymentSent.id === paymentId, paymentSent) + assert(paymentSent.paymentHash === pr.paymentHash, paymentSent) + assert(paymentSent.recipientAmount === amount, paymentSent) + assert(paymentSent.feesPaid === 300000.msat, paymentSent) + assert(paymentSent.nonTrampolineFees === 0.msat, paymentSent) awaitCond(nodes("B").nodeParams.db.payments.getIncomingPayment(pr.paymentHash).exists(_.status.isInstanceOf[IncomingPaymentStatus.Received])) val Some(IncomingPayment(_, _, _, _, IncomingPaymentStatus.Received(receivedAmount, _))) = nodes("B").nodeParams.db.payments.getIncomingPayment(pr.paymentHash) @@ -622,18 +625,20 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with BitcoindService awaitCond(nodes("C").nodeParams.db.audit.listRelayed(start, Platform.currentTime).exists(_.paymentHash == pr.paymentHash)) val relayed = nodes("C").nodeParams.db.audit.listRelayed(start, Platform.currentTime).filter(_.paymentHash == pr.paymentHash).head - assert(relayed.amountIn - relayed.amountOut > 0.msat) - assert(relayed.amountIn - relayed.amountOut < 300000.msat) + assert(relayed.amountIn - relayed.amountOut > 0.msat, relayed) + assert(relayed.amountIn - relayed.amountOut < 300000.msat, relayed) val outgoingSuccess = nodes("D").nodeParams.db.payments.listOutgoingPayments(paymentId).filter(p => p.status.isInstanceOf[OutgoingPaymentStatus.Succeeded]) - outgoingSuccess.foreach { case OutgoingPayment(_, _, _, _, _, _, _, recipientNodeId, _, _, OutgoingPaymentStatus.Succeeded(_, _, route, _)) => - assert(recipientNodeId === nodes("B").nodeParams.nodeId) - assert(route.lastOption === Some(HopSummary(nodes("C").nodeParams.nodeId, nodes("B").nodeParams.nodeId))) + outgoingSuccess.foreach { case p@OutgoingPayment(_, _, _, _, _, _, _, recipientNodeId, _, _, OutgoingPaymentStatus.Succeeded(_, _, route, _)) => + assert(recipientNodeId === nodes("B").nodeParams.nodeId, p) + assert(route.lastOption === Some(HopSummary(nodes("C").nodeParams.nodeId, nodes("B").nodeParams.nodeId)), p) } - assert(outgoingSuccess.map(_.amount).sum === amount + 300000.msat) + assert(outgoingSuccess.map(_.amount).sum === amount + 300000.msat, outgoingSuccess) awaitCond(nodes("D").nodeParams.db.audit.listSent(start, Platform.currentTime).nonEmpty) - assert(nodes("D").nodeParams.db.audit.listSent(start, Platform.currentTime) === Seq(paymentSent.copy(parts = paymentSent.parts.map(_.copy(route = None))))) + val sent = nodes("D").nodeParams.db.audit.listSent(start, Platform.currentTime) + assert(sent.length === 1, sent) + assert(sent.head.copy(parts = sent.head.parts.sortBy(_.timestamp)) === paymentSent.copy(parts = paymentSent.parts.map(_.copy(route = None)).sortBy(_.timestamp)), sent) } test("send a trampoline payment F3->A (via trampoline C, non-trampoline recipient)") { @@ -654,10 +659,10 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with BitcoindService sender.send(nodes("F3").paymentInitiator, payment) val paymentId = sender.expectMsgType[UUID](30 seconds) val paymentSent = sender.expectMsgType[PaymentSent](30 seconds) - assert(paymentSent.id === paymentId) - assert(paymentSent.paymentHash === pr.paymentHash) - assert(paymentSent.recipientAmount === amount) - assert(paymentSent.trampolineFees === 1000000.msat) + assert(paymentSent.id === paymentId, paymentSent) + assert(paymentSent.paymentHash === pr.paymentHash, paymentSent) + assert(paymentSent.recipientAmount === amount, paymentSent) + assert(paymentSent.trampolineFees === 1000000.msat, paymentSent) awaitCond(nodes("A").nodeParams.db.payments.getIncomingPayment(pr.paymentHash).exists(_.status.isInstanceOf[IncomingPaymentStatus.Received])) val Some(IncomingPayment(_, _, _, _, IncomingPaymentStatus.Received(receivedAmount, _))) = nodes("A").nodeParams.db.payments.getIncomingPayment(pr.paymentHash) @@ -665,15 +670,15 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with BitcoindService awaitCond(nodes("C").nodeParams.db.audit.listRelayed(start, Platform.currentTime).exists(_.paymentHash == pr.paymentHash)) val relayed = nodes("C").nodeParams.db.audit.listRelayed(start, Platform.currentTime).filter(_.paymentHash == pr.paymentHash).head - assert(relayed.amountIn - relayed.amountOut > 0.msat) - assert(relayed.amountIn - relayed.amountOut < 1000000.msat) + assert(relayed.amountIn - relayed.amountOut > 0.msat, relayed) + assert(relayed.amountIn - relayed.amountOut < 1000000.msat, relayed) val outgoingSuccess = nodes("F3").nodeParams.db.payments.listOutgoingPayments(paymentId).filter(p => p.status.isInstanceOf[OutgoingPaymentStatus.Succeeded]) - outgoingSuccess.foreach { case OutgoingPayment(_, _, _, _, _, _, _, recipientNodeId, _, _, OutgoingPaymentStatus.Succeeded(_, _, route, _)) => - assert(recipientNodeId === nodes("A").nodeParams.nodeId) - assert(route.lastOption === Some(HopSummary(nodes("C").nodeParams.nodeId, nodes("A").nodeParams.nodeId))) + outgoingSuccess.foreach { case p@OutgoingPayment(_, _, _, _, _, _, _, recipientNodeId, _, _, OutgoingPaymentStatus.Succeeded(_, _, route, _)) => + assert(recipientNodeId === nodes("A").nodeParams.nodeId, p) + assert(route.lastOption === Some(HopSummary(nodes("C").nodeParams.nodeId, nodes("A").nodeParams.nodeId)), p) } - assert(outgoingSuccess.map(_.amount).sum === amount + 1000000.msat) + assert(outgoingSuccess.map(_.amount).sum === amount + 1000000.msat, outgoingSuccess) } test("send a trampoline payment B->D (temporary local failure at trampoline)") { @@ -697,13 +702,13 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with BitcoindService sender.send(nodes("B").paymentInitiator, payment) val paymentId = sender.expectMsgType[UUID](30 seconds) val paymentFailed = sender.expectMsgType[PaymentFailed](30 seconds) - assert(paymentFailed.id === paymentId) - assert(paymentFailed.paymentHash === pr.paymentHash) + assert(paymentFailed.id === paymentId, paymentFailed) + assert(paymentFailed.paymentHash === pr.paymentHash, paymentFailed) assert(nodes("D").nodeParams.db.payments.getIncomingPayment(pr.paymentHash).get.status === IncomingPaymentStatus.Pending) val outgoingPayments = nodes("B").nodeParams.db.payments.listOutgoingPayments(paymentId) - assert(outgoingPayments.nonEmpty) - assert(outgoingPayments.forall(p => p.status.isInstanceOf[OutgoingPaymentStatus.Failed])) + assert(outgoingPayments.nonEmpty, outgoingPayments) + assert(outgoingPayments.forall(p => p.status.isInstanceOf[OutgoingPaymentStatus.Failed]), outgoingPayments) } test("send a trampoline payment A->D (temporary remote failure at trampoline)") { @@ -718,13 +723,13 @@ class IntegrationSpec extends TestKit(ActorSystem("test")) with BitcoindService sender.send(nodes("A").paymentInitiator, payment) val paymentId = sender.expectMsgType[UUID](30 seconds) val paymentFailed = sender.expectMsgType[PaymentFailed](30 seconds) - assert(paymentFailed.id === paymentId) - assert(paymentFailed.paymentHash === pr.paymentHash) + assert(paymentFailed.id === paymentId, paymentFailed) + assert(paymentFailed.paymentHash === pr.paymentHash, paymentFailed) assert(nodes("D").nodeParams.db.payments.getIncomingPayment(pr.paymentHash).get.status === IncomingPaymentStatus.Pending) val outgoingPayments = nodes("A").nodeParams.db.payments.listOutgoingPayments(paymentId) - assert(outgoingPayments.nonEmpty) - assert(outgoingPayments.forall(p => p.status.isInstanceOf[OutgoingPaymentStatus.Failed])) + assert(outgoingPayments.nonEmpty, outgoingPayments) + assert(outgoingPayments.forall(p => p.status.isInstanceOf[OutgoingPaymentStatus.Failed]), outgoingPayments) } /** diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentInitiatorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentInitiatorSpec.scala index 69ff5a3ab..efd96635a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentInitiatorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentInitiatorSpec.scala @@ -28,11 +28,11 @@ import fr.acinq.eclair.payment.PaymentPacketSpec._ import fr.acinq.eclair.payment.PaymentRequest.{ExtraHop, Features} import fr.acinq.eclair.payment.send.MultiPartPaymentLifecycle.SendMultiPartPayment import fr.acinq.eclair.payment.send.PaymentInitiator -import fr.acinq.eclair.payment.send.PaymentInitiator.{SendPaymentConfig, SendPaymentRequest, SendTrampolinePaymentRequest, TrampolineLegacyAmountLessInvoice} +import fr.acinq.eclair.payment.send.PaymentInitiator._ import fr.acinq.eclair.payment.send.PaymentLifecycle.{SendPayment, SendPaymentToRoute} -import fr.acinq.eclair.router.RouteParams +import fr.acinq.eclair.router.{NodeHop, RouteParams} import fr.acinq.eclair.wire.Onion.FinalLegacyPayload -import fr.acinq.eclair.wire.{OnionCodecs, OnionTlv, TrampolineFeeInsufficient} +import fr.acinq.eclair.wire.{Onion, OnionCodecs, OnionTlv, TrampolineFeeInsufficient} import fr.acinq.eclair.{CltvExpiryDelta, LongToBtcAmount, NodeParams, TestConstants, randomBytes32, randomKey} import org.scalatest.{Outcome, Tag, fixture} import scodec.bits.HexStringSyntax @@ -83,9 +83,10 @@ class PaymentInitiatorSpec extends TestKit(ActorSystem("test")) with fixture.Fun test("forward payment with pre-defined route") { f => import f._ - sender.send(initiator, SendPaymentRequest(finalAmount, paymentHash, c, 1, predefinedRoute = Seq(a, b, c))) - val paymentId = sender.expectMsgType[UUID] - payFsm.expectMsg(SendPaymentConfig(paymentId, paymentId, None, paymentHash, finalAmount, c, Upstream.Local(paymentId), None, storeInDb = true, publishEvent = true, Nil)) + val pr = PaymentRequest(Block.LivenetGenesisBlock.hash, Some(finalAmount), paymentHash, priv_c.privateKey, "Some invoice", features = None) + sender.send(initiator, SendPaymentToRouteRequest(finalAmount, finalAmount, None, None, pr, Channel.MIN_CLTV_EXPIRY_DELTA, Seq(a, b, c), None, 0 msat, CltvExpiryDelta(0), Nil)) + val payment = sender.expectMsgType[SendPaymentToRouteResponse] + payFsm.expectMsg(SendPaymentConfig(payment.paymentId, payment.parentId, None, paymentHash, finalAmount, c, Upstream.Local(payment.paymentId), Some(pr), storeInDb = true, publishEvent = true, Nil)) payFsm.expectMsg(SendPaymentToRoute(Seq(a, b, c), FinalLegacyPayload(finalAmount, Channel.MIN_CLTV_EXPIRY_DELTA.toCltvExpiry(nodeParams.currentBlockHeight + 1)))) } @@ -126,11 +127,11 @@ class PaymentInitiatorSpec extends TestKit(ActorSystem("test")) with fixture.Fun test("forward multi-part payment with pre-defined route") { f => import f._ - val pr = PaymentRequest(Block.LivenetGenesisBlock.hash, Some(finalAmount), paymentHash, randomKey, "Some invoice", features = Some(Features(VariableLengthOnion.optional, PaymentSecret.optional, BasicMultiPartPayment.optional))) - val req = SendPaymentRequest(finalAmount / 2, paymentHash, c, 1, paymentRequest = Some(pr), predefinedRoute = Seq(a, b, c)) + val pr = PaymentRequest(Block.LivenetGenesisBlock.hash, Some(finalAmount), paymentHash, priv_c.privateKey, "Some invoice", features = Some(Features(VariableLengthOnion.optional, PaymentSecret.optional, BasicMultiPartPayment.optional))) + val req = SendPaymentToRouteRequest(finalAmount / 2, finalAmount, None, None, pr, Channel.MIN_CLTV_EXPIRY_DELTA, Seq(a, b, c), None, 0 msat, CltvExpiryDelta(0), Nil) sender.send(initiator, req) - val id = sender.expectMsgType[UUID] - payFsm.expectMsg(SendPaymentConfig(id, id, None, paymentHash, finalAmount / 2, c, Upstream.Local(id), Some(pr), storeInDb = true, publishEvent = true, Nil)) + val payment = sender.expectMsgType[SendPaymentToRouteResponse] + payFsm.expectMsg(SendPaymentConfig(payment.paymentId, payment.parentId, None, paymentHash, finalAmount, c, Upstream.Local(payment.paymentId), Some(pr), storeInDb = true, publishEvent = true, Nil)) val msg = payFsm.expectMsgType[SendPaymentToRoute] assert(msg.hops === Seq(a, b, c)) assert(msg.finalPayload.amount === finalAmount / 2) @@ -286,4 +287,32 @@ class PaymentInitiatorSpec extends TestKit(ActorSystem("test")) with fixture.Fun eventListener.expectNoMsg(100 millis) } + test("forward trampoline payment with pre-defined route") { f => + import f._ + val pr = PaymentRequest(Block.LivenetGenesisBlock.hash, Some(finalAmount), paymentHash, priv_c.privateKey, "Some invoice") + val trampolineFees = 100 msat + val req = SendPaymentToRouteRequest(finalAmount + trampolineFees, finalAmount, None, None, pr, Channel.MIN_CLTV_EXPIRY_DELTA, Seq(a, b), None, trampolineFees, CltvExpiryDelta(144), Seq(b, c)) + sender.send(initiator, req) + val payment = sender.expectMsgType[SendPaymentToRouteResponse] + assert(payment.trampolineSecret.nonEmpty) + payFsm.expectMsg(SendPaymentConfig(payment.paymentId, payment.parentId, None, paymentHash, finalAmount, c, Upstream.Local(payment.paymentId), Some(pr), storeInDb = true, publishEvent = true, Seq(NodeHop(b, c, CltvExpiryDelta(0), 0 msat)))) + val msg = payFsm.expectMsgType[SendPaymentToRoute] + assert(msg.hops === Seq(a, b)) + assert(msg.finalPayload.amount === finalAmount + trampolineFees) + assert(msg.finalPayload.paymentSecret === payment.trampolineSecret) + assert(msg.finalPayload.totalAmount === finalAmount + trampolineFees) + assert(msg.finalPayload.isInstanceOf[Onion.FinalTlvPayload]) + val trampolineOnion = msg.finalPayload.asInstanceOf[Onion.FinalTlvPayload].records.get[OnionTlv.TrampolineOnion] + assert(trampolineOnion.nonEmpty) + + // Verify that the trampoline node can correctly peel the trampoline onion. + val Right(decrypted) = Sphinx.TrampolinePacket.peel(priv_b.privateKey, pr.paymentHash, trampolineOnion.get.packet) + assert(!decrypted.isLastPacket) + val trampolinePayload = OnionCodecs.nodeRelayPerHopPayloadCodec.decode(decrypted.payload.bits).require.value + assert(trampolinePayload.amountToForward === finalAmount) + assert(trampolinePayload.totalAmount === finalAmount) + assert(trampolinePayload.outgoingNodeId === c) + assert(trampolinePayload.paymentSecret === pr.paymentSecret) + } + } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/JsonSerializers.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/JsonSerializers.scala index f650c5288..8c91e809a 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/JsonSerializers.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/JsonSerializers.scala @@ -261,7 +261,6 @@ object CustomTypeHints { classOf[OutgoingPaymentStatus.Succeeded] -> "sent" )) - // TODO: @t-bast: don't forget to update slate-doc val paymentEvent = CustomTypeHints(Map( classOf[PaymentSent] -> "payment-sent", classOf[ChannelPaymentRelayed] -> "payment-relayed", diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala index 51bceb193..0c50620cf 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala @@ -221,18 +221,6 @@ trait Service extends ExtraDirectives with Logging { case _ => reject(MalformedFormFieldRejection("invoice", "The invoice must have an amount or you need to specify one using the field 'amountMsat'")) } } ~ - // TODO: @t-bast: remove this API once stabilized: should re-work the payment APIs to integrate Trampoline nicely - // - payinvoice: should stay the same, it's the easy flow where the node does its magic - // - sendtonode: exactly like payinvoice but without an invoice: let the node do its magic - // - sendtoroute: no path-finding, lets the user control exactly how to send (provide multiple routes, with trampoline or not, etc) -> maybe doesn't go through normal PayFSM (avoid retries) - // -> maybe somehow make one call per partial HTLC (allows easier failure reporting and out-of-node retry logic)? - // -> needs both trampolineRoute and routeToTrampoline arguments? - path("sendtotrampoline") { - formFields(invoiceFormParam, "trampolineId".as[PublicKey], "trampolineFeesMsat".as[MilliSatoshi], "trampolineExpiryDelta".as[Int]) { - (invoice, trampolineId, trampolineFees, trampolineExpiryDelta) => - complete(eclairApi.sendToTrampoline(invoice, trampolineId, trampolineFees, CltvExpiryDelta(trampolineExpiryDelta))) - } - } ~ path("sendtonode") { formFields(amountMsatFormParam, paymentHashFormParam, nodeIdFormParam, "maxAttempts".as[Int].?, "feeThresholdSat".as[Satoshi].?, "maxFeePct".as[Double].?, "externalId".?) { (amountMsat, paymentHash, nodeId, maxAttempts_opt, feeThresholdSat_opt, maxFeePct_opt, externalId_opt) => @@ -240,9 +228,9 @@ trait Service extends ExtraDirectives with Logging { } } ~ path("sendtoroute") { - formFields(amountMsatFormParam, paymentHashFormParam, "finalCltvExpiry".as[Int], "route".as[List[PublicKey]](pubkeyListUnmarshaller), "externalId".?, invoiceFormParam.?) { - (amountMsat, paymentHash, finalCltvExpiry, route, externalId_opt, invoice_opt) => - complete(eclairApi.sendToRoute(externalId_opt, route, amountMsat, paymentHash, CltvExpiryDelta(finalCltvExpiry), invoice_opt)) + formFields(amountMsatFormParam, "recipientAmountMsat".as[MilliSatoshi].?, invoiceFormParam, "finalCltvExpiry".as[Int], "route".as[List[PublicKey]](pubkeyListUnmarshaller), "externalId".?, "parentId".as[UUID].?, "trampolineSecret".as[ByteVector32].?, "trampolineFeesMsat".as[MilliSatoshi].?, "trampolineCltvExpiry".as[Int].?, "trampolineNodes".as[List[PublicKey]](pubkeyListUnmarshaller).?) { + (amountMsat, recipientAmountMsat_opt, invoice, finalCltvExpiry, route, externalId_opt, parentId_opt, trampolineSecret_opt, trampolineFeesMsat_opt, trampolineCltvExpiry_opt, trampolineNodes_opt) => + complete(eclairApi.sendToRoute(amountMsat, recipientAmountMsat_opt, externalId_opt, parentId_opt, invoice, CltvExpiryDelta(finalCltvExpiry), route, trampolineSecret_opt, trampolineFeesMsat_opt, trampolineCltvExpiry_opt.map(CltvExpiryDelta), trampolineNodes_opt.getOrElse(Nil))) } } ~ path("getsentinfo") { diff --git a/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala b/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala index e708b5dbe..84d952e98 100644 --- a/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala +++ b/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala @@ -30,10 +30,11 @@ 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, PaymentType} +import fr.acinq.eclair.db._ 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.send.PaymentInitiator.SendPaymentToRouteResponse import fr.acinq.eclair.payment.{PaymentFailed, _} import fr.acinq.eclair.router.{NetworkStats, Stats} import fr.acinq.eclair.wire.NodeAddress @@ -400,8 +401,8 @@ class ApiServiceSpec extends FunSuite with ScalatestRouteTest with IdiomaticMock } 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 payment = SendPaymentToRouteResponse(UUID.fromString("487da196-a4dc-4b1e-92b4-3e5e905e9f3f"), UUID.fromString("2ad8c6d7-99cb-4238-8f67-89024b8eed0d"), None) + val expected = """{"paymentId":"487da196-a4dc-4b1e-92b4-3e5e905e9f3f","parentId":"2ad8c6d7-99cb-4238-8f67-89024b8eed0d"}""" 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")) @@ -409,30 +410,30 @@ class ApiServiceSpec extends FunSuite with ScalatestRouteTest with IdiomaticMock 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) + eclair.sendToRoute(any[MilliSatoshi], any[Option[MilliSatoshi]], any[Option[String]], any[Option[UUID]], any[PaymentRequest], any[CltvExpiryDelta], any[List[PublicKey]], any[Option[ByteVector32]], any[Option[MilliSatoshi]], any[Option[CltvExpiryDelta]], any[List[PublicKey]])(any[Timeout]) returns Future.successful(payment) 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) ~> + Post("/sendtoroute", FormData("route" -> jsonNodes, "amountMsat" -> "1234", "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) + assert(entityAs[String] == expected) + eclair.sendToRoute(1234 msat, None, Some(externalId), None, pr, CltvExpiryDelta(190), expectedRoute, None, None, None, Nil)(any[Timeout]).wasCalled(once) } // this test uses CSV encoded route - Post("/sendtoroute", FormData("route" -> csvNodes, "amountMsat" -> "1234", "paymentHash" -> ByteVector32.One.toHex, "finalCltvExpiry" -> "190").toEntity) ~> + Post("/sendtoroute", FormData("route" -> csvNodes, "amountMsat" -> "1234", "finalCltvExpiry" -> "190", "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(None, expectedRoute, 1234 msat, ByteVector32.One, CltvExpiryDelta(190), None)(any[Timeout]).wasCalled(once) + assert(entityAs[String] == expected) + eclair.sendToRoute(1234 msat, None, None, None, pr, CltvExpiryDelta(190), expectedRoute, None, None, None, Nil)(any[Timeout]).wasCalled(once) } } @@ -440,7 +441,7 @@ class ApiServiceSpec extends FunSuite with ScalatestRouteTest with IdiomaticMock 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 feePropStat = Stats(32L, 11L, 13L, 22L, 42L, 51L, 53L) val networkStats = new NetworkStats(1, 2, capStat, cltvStat, feeBaseStat, feePropStat) val eclair = mock[Eclair]