What changed, and why it matters
This commit is a bundled maintenance patch for the Eclair Lightning node. It removes sensitive cryptographic keys and shared secrets from debug/error logs, fixes a bug where rejected payments could still be counted when calculating trampoline routing fees, prevents state leaks in manually crafted multi-part payments, makes incoming-connection tracking idempotent, avoids unnecessary peer wake-up attempts, handles an edge case in liquidity-ads encoding, separates database SELECT and DELETE operations for safety, and correctly recognizes wallet-encoded node IDs when resolving blinded payment paths. Several of these changes have clear security relevance, particularly the logging cleanup and the trampoline-fee fix, but they are defensive hardening and bug fixes rather than a single critical vulnerability.
Apply the patch and review node log retention policies to ensure any historical DEBUG logs containing the removed key material are purged. Operators should also monitor for any unexpected trampoline fee behavior and verify that manually routed MPP payments clean up state correctly after the fix.
Security signals we found
Sensitive key material removed from DEBUG logs (ChaCha20Poly1305)
Onion shared secrets removed from failure-packet logs (PaymentLifecycle)
Trampoline fee budget could be inflated by rejected/extraneous HTLCs before fix (NodeRelay)
PaymentInitiator state leak for manually crafted MPP payments before fix
Blinded path introduction-point check bypassed by wallet-encoded node ID before fix (BlindedPathsResolver)
Database cleanup interleaved SELECT/DELETE before fix (PgNetworkDb)
Redundant incoming connection tracking could cause unintended disconnections before fix (IncomingConnectionsTracker)
Unnecessary peer wake-up actors created when feature disabled before fix (MessageRelay)
Evidence from the diff
The commit applies eight distinct improvements: (1) removes logger.debug calls in ChaCha20Poly1305 that logged key, nonce, aad, plaintext, ciphertext, mac; (2) removes sharedSecrets from the CannotDecryptFailurePacket warning log in PaymentLifecycle; (3) makes IncomingConnectionsTracker ignore redundant TrackIncomingConnection requests for the same node instead of treating them as new connections; (4) splits SELECT and DELETE in PgNetworkDb restart cleanup to avoid driver-dependent interleaving; (5) checks nodeParams.peerWakeUpConfig.enabled before spawning a PeerReadyNotifier in MessageRelay; (6) matches any EncodedNodeId.WithPublicKey subtype (including Wallet) when checking if the local node is the introduction point in BlindedPathsResolver; (7) filters htlcs to only accepted parts before building Upstream.Hot.Trampoline in NodeRelay, preventing extraneous/rejected HTLCs from inflating the trampoline fee budget; (8) cleans up child payment IDs in PaymentInitiator when PaymentSent uses the parent ID for manually routed MPP; and (9) handles empty paymentTypes in LiquidityAds.WillFundRates without calling max on an empty set. Tests are added for most changes.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/crypto/ChaCha20Poly1305.scalaeclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgNetworkDb.scalaeclair-core/src/main/scala/fr/acinq/eclair/io/IncomingConnectionsTracker.scalaeclair-core/src/main/scala/fr/acinq/eclair/io/MessageRelay.scalaeclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scalaeclair-core/src/main/scala/fr/acinq/eclair/payment/send/BlindedPathsResolver.scalaeclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentInitiator.scalaeclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scalaeclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scalaInspect captured patch +167 / −50
### eclair-core/src/main/scala/fr/acinq/eclair/crypto/ChaCha20Poly1305.scala
@@ -18,7 +18,6 @@ package fr.acinq.eclair.crypto
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Protocol}
import fr.acinq.eclair.crypto.ChaCha20Poly1305.{DecryptionError, EncryptionError, InvalidCounter}
-import grizzled.slf4j.Logging
import org.bouncycastle.crypto.engines.ChaCha7539Engine
import org.bouncycastle.crypto.params.{KeyParameter, ParametersWithIV}
import scodec.bits.ByteVector
@@ -91,7 +90,7 @@ object ChaCha20 {
*
* This what we should be using (see BOLT #8)
*/
-object ChaCha20Poly1305 extends Logging {
+object ChaCha20Poly1305 {
// @formatter:off
abstract class ChaCha20Poly1305Error(msg: String) extends RuntimeException(msg)
@@ -112,7 +111,6 @@ object ChaCha20Poly1305 extends Logging {
val polykey = ChaCha20.encrypt(ByteVector32.Zeroes, key, nonce)
val ciphertext = ChaCha20.encrypt(plaintext, key, nonce, 1)
val tag = Poly1305.mac(polykey, aad, pad16(aad), ciphertext, pad16(ciphertext), Protocol.writeUInt64(aad.length, ByteOrder.LITTLE_ENDIAN), Protocol.writeUInt64(ciphertext.length, ByteOrder.LITTLE_ENDIAN))
- logger.debug(s"encrypt($key, $nonce, $aad, $plaintext) = ($ciphertext, $tag)")
(ciphertext, tag)
}
@@ -129,11 +127,10 @@ object ChaCha20Poly1305 extends Logging {
val tag = Poly1305.mac(polykey, aad, pad16(aad), ciphertext, pad16(ciphertext), Protocol.writeUInt64(aad.length, ByteOrder.LITTLE_ENDIAN), Protocol.writeUInt64(ciphertext.length, ByteOrder.LITTLE_ENDIAN))
if (tag != mac) throw InvalidMac()
val plaintext = ChaCha20.decrypt(ciphertext, key, nonce, 1)
- logger.debug(s"decrypt($key, $nonce, $aad, $ciphertext, $mac) = $plaintext")
plaintext
}
- def pad16(data: ByteVector): ByteVector =
+ private def pad16(data: ByteVector): ByteVector =
if (data.size % 16 == 0)
ByteVector.empty
else
### eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgNetworkDb.scala
@@ -80,15 +80,18 @@ class PgNetworkDb(implicit ds: DataSource) extends NetworkDb with Logging {
}
case Some(CURRENT_VERSION) =>
// We clean up channels that contain an invalid channel update (e.g. missing htlc_maximum_msat).
- statement.executeQuery("SELECT short_channel_id, channel_update_1, channel_update_2 FROM network.public_channels").map(rs => {
- val shortChannelId = rs.getLong("short_channel_id")
- val validChannelUpdate1 = rs.getBitVectorOpt("channel_update_1").forall(channelUpdateCodec.decode(_).isSuccessful)
- val validChannelUpdate2 = rs.getBitVectorOpt("channel_update_2").forall(channelUpdateCodec.decode(_).isSuccessful)
- (shortChannelId, validChannelUpdate1 && validChannelUpdate2)
- }).collect {
- case (scid, false) =>
- logger.warn(s"removing channel update with scid=$scid from the network DB (update cannot be decoded)")
- statement.executeUpdate(s"DELETE FROM network.public_channels WHERE short_channel_id=$scid")
+ val invalidChannels = statement.executeQuery("SELECT short_channel_id, channel_update_1, channel_update_2 FROM network.public_channels")
+ .map { rs =>
+ val shortChannelId = rs.getLong("short_channel_id")
+ val validChannelUpdate1 = rs.getBitVectorOpt("channel_update_1").forall(channelUpdateCodec.decode(_).isSuccessful)
+ val validChannelUpdate2 = rs.getBitVectorOpt("channel_update_2").forall(channelUpdateCodec.decode(_).isSuccessful)
+ (shortChannelId, validChannelUpdate1 && validChannelUpdate2)
+ }.collect {
+ case (scid, false) => scid
+ }.toList
+ invalidChannels.foreach { scid =>
+ logger.warn(s"removing channel update with scid=$scid from the network DB (update cannot be decoded)")
+ statement.executeUpdate(s"DELETE FROM network.public_channels WHERE short_channel_id=$scid")
}
case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion")
}
### eclair-core/src/main/scala/fr/acinq/eclair/io/IncomingConnectionsTracker.scala
@@ -7,10 +7,10 @@ import akka.actor.typed.{ActorRef, Behavior}
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.eclair.Logs.LogCategory
import fr.acinq.eclair.channel.ChannelReadyForPayments
-import fr.acinq.eclair.{Logs, NodeParams}
import fr.acinq.eclair.io.IncomingConnectionsTracker.Command
import fr.acinq.eclair.io.Monitoring.Metrics
import fr.acinq.eclair.io.Peer.{Disconnect, DisconnectResponse}
+import fr.acinq.eclair.{Logs, NodeParams}
/**
* A singleton actor that limits the total number of incoming connections from peers that do not have channels with us.
@@ -32,11 +32,10 @@ import fr.acinq.eclair.io.Peer.{Disconnect, DisconnectResponse}
* create a continuous stream of incoming connections with random nodeIds, which forces us to constantly disconnect old
* connections before they have the opportunity to open a channel. This can be fixed by adding a TCP rate-limiter that
* rejects connections based on IP addresses, which forces the attacker to own a lot of IP addresses.
-*/
+ */
object IncomingConnectionsTracker {
// @formatter:off
sealed trait Command
-
case class TrackIncomingConnection(remoteNodeId: PublicKey) extends Command
private[io] case class ForgetIncomingConnection(remoteNodeId: PublicKey) extends Command
private[io] case class CountIncomingConnections(replyTo: ActorRef[Int]) extends Command
@@ -54,6 +53,7 @@ object IncomingConnectionsTracker {
}
private class IncomingConnectionsTracker(nodeParams: NodeParams, switchboard: ActorRef[Disconnect], context: ActorContext[Command]) {
+
import IncomingConnectionsTracker._
private def tracking(incomingConnections: Map[PublicKey, TimestampMillis]): Behavior[Command] = {
@@ -62,17 +62,16 @@ private class IncomingConnectionsTracker(nodeParams: NodeParams, switchboard: Ac
case TrackIncomingConnection(remoteNodeId) =>
if (nodeParams.routerConf.syncConf.whitelist.contains(remoteNodeId)) {
Behaviors.same
+ } else if (incomingConnections.contains(remoteNodeId)) {
+ Behaviors.same
+ } else if (incomingConnections.size >= nodeParams.peerConnectionConf.maxNoChannels) {
+ Metrics.IncomingConnectionsDisconnected.withoutTags().increment()
+ val oldest = incomingConnections.minBy(_._2)._1
+ context.log.warn(s"disconnecting peer=$oldest, too many incoming connections from peers without channels.")
+ switchboard ! Disconnect(oldest, Some(context.system.ignoreRef[DisconnectResponse]))
+ tracking(incomingConnections + (remoteNodeId -> System.currentTimeMillis()) - oldest)
} else {
- if (incomingConnections.size >= nodeParams.peerConnectionConf.maxNoChannels) {
- Metrics.IncomingConnectionsDisconnected.withoutTags().increment()
- val oldest = incomingConnections.minBy(_._2)._1
- context.log.warn(s"disconnecting peer=$oldest, too many incoming connections from peers without channels.")
- switchboard ! Disconnect(oldest, Some(context.system.ignoreRef[DisconnectResponse]))
- tracking(incomingConnections + (remoteNodeId -> System.currentTimeMillis()) - oldest)
- }
- else {
- tracking(incomingConnections + (remoteNodeId -> System.currentTimeMillis()))
- }
+ tracking(incomingConnections + (remoteNodeId -> System.currentTimeMillis()))
}
case ForgetIncomingConnection(remoteNodeId) => tracking(incomingConnections - remoteNodeId)
case CountIncomingConnections(replyTo) =>
### eclair-core/src/main/scala/fr/acinq/eclair/io/MessageRelay.scala
@@ -146,9 +146,16 @@ private class MessageRelay(nodeParams: NodeParams,
waitForConnection(msg, nodeId)
}
case EncodedNodeId.WithPublicKey.Wallet(nodeId) =>
- val notifier = context.spawnAnonymous(PeerReadyNotifier(nodeId, timeout_opt = Some(Left(nodeParams.peerWakeUpConfig.timeout))))
- notifier ! PeerReadyNotifier.NotifyWhenPeerReady(context.messageAdapter(WrappedPeerReadyResult))
- waitForWalletNodeUp(msg, nodeId)
+ if (nodeParams.peerWakeUpConfig.enabled) {
+ val notifier = context.spawnAnonymous(PeerReadyNotifier(nodeId, timeout_opt = Some(Left(nodeParams.peerWakeUpConfig.timeout))))
+ notifier ! PeerReadyNotifier.NotifyWhenPeerReady(context.messageAdapter(WrappedPeerReadyResult))
+ waitForWalletNodeUp(msg, nodeId)
+ } else {
+ Metrics.OnionMessagesNotRelayed.withTag(Tags.Reason, Tags.Reasons.ConnectionFailure).increment()
+ log.info("could not wake up {}: peer wake-up is disabled", nodeId)
+ replyTo_opt.foreach(_ ! Disconnected(messageId))
+ Behaviors.stopped
+ }
}
}
### eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala
@@ -235,7 +235,11 @@ class NodeRelay private(nodeParams: NodeParams,
stopping()
case WrappedMultiPartPaymentSucceeded(MultiPartPaymentFSM.MultiPartPaymentSucceeded(_, parts)) =>
context.log.info("completed incoming multi-part payment with parts={} paidAmount={}", parts.size, parts.map(_.amount).sum)
- val upstream = Upstream.Hot.Trampoline(htlcs.toList)
+ // Note that we must filter based on the actually accepted parts, otherwise we may include extraneous HTLCs
+ // that have been failed by the MultiPartPaymentFSM.
+ val acceptedHtlcs = parts.collect { case p: MultiPartPaymentFSM.HtlcPart => (p.htlc.channelId, p.htlc.id) }.toSet
+ val acceptedUpstream = htlcs.filter(htlc => acceptedHtlcs.contains((htlc.add.channelId, htlc.add.id)))
+ val upstream = Upstream.Hot.Trampoline(acceptedUpstream.toList)
validateRelay(nodeParams, upstream, nextPayload) match {
case Some(failure) =>
context.log.warn(s"rejecting trampoline payment reason=$failure")
### eclair-core/src/main/scala/fr/acinq/eclair/payment/send/BlindedPathsResolver.scala
@@ -80,10 +80,10 @@ private class BlindedPathsResolver(nodeParams: NodeParams,
private def resolveBlindedPaths(toResolve: Seq[PaymentBlindedRoute], resolved: Seq[ResolvedPath]): Behavior[Command] = {
toResolve.headOption match {
case Some(paymentRoute) => paymentRoute.route.firstNodeId match {
- case EncodedNodeId.WithPublicKey.Plain(ourNodeId) if ourNodeId == nodeParams.nodeId && paymentRoute.route.length == 0 =>
+ case ourNodeId: EncodedNodeId.WithPublicKey if ourNodeId.publicKey == nodeParams.nodeId && paymentRoute.route.length == 0 =>
context.log.warn("ignoring blinded path (empty route with ourselves as the introduction node)")
resolveBlindedPaths(toResolve.tail, resolved)
- case EncodedNodeId.WithPublicKey.Plain(ourNodeId) if ourNodeId == nodeParams.nodeId =>
+ case ourNodeId: EncodedNodeId.WithPublicKey if ourNodeId.publicKey == nodeParams.nodeId =>
// We are the introduction node of the blinded route: we need to decrypt the first payload.
val firstPathKey = paymentRoute.route.firstNode.pathKey
val firstEncryptedPayload = paymentRoute.route.firstNode.encryptedPayload
### eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentInitiator.scala
@@ -115,10 +115,12 @@ class PaymentInitiator(nodeParams: NodeParams, outgoingPaymentFactory: PaymentIn
context become main(pending - pf.id)
}
- case ps: PaymentSent => pending.get(ps.id).foreach { pp =>
- pp.sender ! ps
- context become main(pending - ps.id)
- }
+ case ps: PaymentSent =>
+ pending.get(ps.id).foreach(_.sender ! ps)
+ // When directly using SendPaymentToRoute to manually send MPP payments, the paymentID of the child was used as
+ // key instead of the parent payment ID.
+ ps.parts.filter(_.id != ps.id).foreach(part => pending.get(part.id).foreach(_.sender ! ps))
+ context become main(pending - ps.id -- ps.parts.map(_.id).toSet)
case GetPayment(id) =>
val pending_opt = id match {
### eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala
@@ -249,7 +249,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
}
RemoteFailure(request.amount, route.fullRoute, e, startedAt = d.sentAt, failedAt = now)
case Left(e@Sphinx.CannotDecryptFailurePacket(unwrapped, _)) =>
- log.warning(s"cannot parse returned error ${fail.reason.toHex} with sharedSecrets=$sharedSecrets: unwrapped=$unwrapped")
+ log.warning(s"cannot parse returned error ${fail.reason.toHex}: unwrapped=$unwrapped")
UnreadableRemoteFailure(request.amount, route.fullRoute, e, startedAt = d.sentAt, failedAt = now, htlcFailure.holdTimes)
}
log.warning(s"too many failed attempts, failing the payment")
### eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala
@@ -176,13 +176,17 @@ object LiquidityAds {
object WillFundRates {
def apply(fundingRates: List[FundingRate], paymentTypes: Set[PaymentType]): WillFundRates = {
- val indexes = paymentTypes.map(_.bitIndex)
- // When converting from BitVector to ByteVector, scodec pads right instead of left, so we make sure we pad to
- // bytes *before* setting bits.
- var buf = BitVector.fill(indexes.max + 1)(high = false).bytes.bits
- indexes.foreach { i => buf = buf.set(i) }
- val encoded = buf.reverse.bytes
- WillFundRates(fundingRates, encoded)
+ val encodedPaymentTypes = if (paymentTypes.nonEmpty) {
+ val indexes = paymentTypes.map(_.bitIndex)
+ // When converting from BitVector to ByteVector, scodec pads right instead of left, so we make sure we pad to
+ // bytes *before* setting bits.
+ var buf = BitVector.fill(indexes.max + 1)(high = false).bytes.bits
+ indexes.foreach { i => buf = buf.set(i) }
+ buf.reverse.bytes
+ } else {
+ ByteVector.empty
+ }
+ WillFundRates(fundingRates, encodedPaymentTypes)
}
private def hasPaymentType(bitIndex: Int, encoded: ByteVector): Boolean = {
### eclair-core/src/test/scala/fr/acinq/eclair/db/NetworkDbSpec.scala
@@ -359,6 +359,26 @@ class NetworkDbSpec extends AnyFunSuite {
}
}
+ test("remove multiple invalid channel updates on postgres restart") {
+ val dbs = TestPgDatabases()
+ try {
+ val t1 = channelTestCases(0)
+ val t2 = channelTestCases(1)
+ val db1 = dbs.network
+ db1.addChannel(t1.channel, t1.txid, t1.capacity)
+ db1.addChannel(t2.channel, t2.txid, t2.capacity)
+ val channelUpdateWithoutHtlcMax = hex"12540b6a236e21932622d61432f52913d9442cc09a1057c386119a286153f8681c66d2a0f17d32505ba71bb37c8edcfa9c11e151b2b38dae98b825eff1c040b36fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d619000000000008850f00058e00015e6a782e0000009000000000000003e8000003e800000002"
+ using(dbs.connection.prepareStatement("UPDATE network.public_channels SET channel_update_1=?")) { statement =>
+ statement.setBytes(1, channelUpdateWithoutHtlcMax.toArray)
+ statement.executeUpdate()
+ }
+ val db2 = new PgNetworkDb()(dbs.datasource)
+ assert(db2.listChannels().isEmpty)
+ } finally {
+ dbs.close()
+ }
+ }
+
test("json column reset (postgres)") {
val dbs = TestPgDatabases()
val db = dbs.network
### eclair-core/src/test/scala/fr/acinq/eclair/io/IncomingConnectionsTrackerSpec.scala
@@ -56,6 +56,22 @@ class IncomingConnectionsTrackerSpec extends ScalaTestWithActorTestKit(ConfigFac
assert(switchboard.expectMessageType[Disconnect].nodeId === connection2)
}
+ test("keep a replacement connection tracked when the oldest node reconnects") { _ =>
+ val nodeParams1 = nodeParams.copy(peerConnectionConf = nodeParams.peerConnectionConf.copy(maxNoChannels = 1))
+ val switchboard = TestProbe[Disconnect]()
+ val tracker = testKit.spawn(IncomingConnectionsTracker(nodeParams1, switchboard.ref))
+ val count = TestProbe[Int]()
+
+ tracker ! IncomingConnectionsTracker.TrackIncomingConnection(connection1)
+ tracker ! IncomingConnectionsTracker.TrackIncomingConnection(connection1)
+
+ // Re-authenticating the same node replaces its previous connection. It must not trigger a
+ // node-wide disconnect or remove that node from the tracker.
+ switchboard.expectNoMessage(100 millis)
+ tracker ! IncomingConnectionsTracker.CountIncomingConnections(count.ref)
+ count.expectMessage(1)
+ }
+
test("stop tracking a node that disconnects and free space for a new node connection") { f =>
import f._
### eclair-core/src/test/scala/fr/acinq/eclair/io/MessageRelaySpec.scala
@@ -116,6 +116,14 @@ class MessageRelaySpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app
assert(peer.expectMessageType[Peer.RelayOnionMessage].msg == message)
}
+ test("does not wake up wallet nodes when peer wake-up is disabled") { f =>
+ import f._
+
+ val Right(message) = OnionMessages.buildMessage(randomKey(), randomKey(), Seq(), Recipient(bobId, None), TlvStream.empty)
+ relay ! RelayMessage(randomBytes32(), randomKey().publicKey, Right(EncodedNodeId.WithPublicKey.Wallet(bobId)), message, RelayChannelsOnly, None)
+ peerReadyManager.expectNoMessage(100 millis)
+ }
+
test("can't open new connection") { f =>
import f._
@@ -220,7 +228,7 @@ class MessageRelaySpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app
relay ! RelayMessage(messageId, randomKey().publicKey, Right(EncodedNodeId.ShortChannelIdDir(isNode1 = false, scid)), message, RelayAll, None)
val getNodeId = router.expectMessageType[Router.GetNodeId]
- assert(getNodeId.isNode1 == false)
+ assert(!getNodeId.isNode1)
assert(getNodeId.shortChannelId == scid)
getNodeId.replyTo ! Some(bobId)
@@ -239,7 +247,7 @@ class MessageRelaySpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app
relay ! RelayMessage(messageId, randomKey().publicKey, Right(EncodedNodeId.ShortChannelIdDir(isNode1 = true, scid)), message, RelayAll, None)
val getNodeId = router.expectMessageType[Router.GetNodeId]
- assert(getNodeId.isNode1 == true)
+ assert(getNodeId.isNode1)
assert(getNodeId.shortChannelId == scid)
getNodeId.replyTo ! Some(aliceId)
@@ -266,7 +274,7 @@ class MessageRelaySpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app
relay ! RelayMessage(messageId, randomKey().publicKey, Right(EncodedNodeId(aliceId)), message, RelayAll, None)
val getNodeId = router.expectMessageType[Router.GetNodeId]
- assert(getNodeId.isNode1 == false)
+ assert(!getNodeId.isNode1)
assert(getNodeId.shortChannelId == RealShortChannelId(123L))
getNodeId.replyTo ! Some(aliceId)
### eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentInitiatorSpec.scala
@@ -192,6 +192,31 @@ class PaymentInitiatorSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
sender.expectMsg(NoPendingPayment(PaymentIdentifier.PaymentHash(invoice.paymentHash)))
}
+ test("clear successful payment with pre-defined route") { f =>
+ import f._
+ val invoice = Bolt11Invoice(Block.LivenetGenesisBlock.hash, Some(finalAmount), paymentHash, priv_c.privateKey, Left("Some invoice"), CltvExpiryDelta(18))
+ val route = PredefinedNodeRoute(finalAmount, Seq(a, b, c))
+ val request = SendPaymentToRoute(finalAmount, invoice, Nil, route, None, None)
+ sender.send(initiator, request)
+ val payment = sender.expectMsgType[SendPaymentToRouteResponse]
+ payFsm.expectMsgType[SendPaymentConfig]
+ payFsm.expectMsgType[PaymentLifecycle.SendPaymentToRoute]
+
+ val paymentSent = PaymentSent(
+ payment.parentId,
+ paymentPreimage,
+ finalAmount,
+ priv_c.publicKey,
+ Seq(PaymentPart(payment.paymentId, PaymentEvent.OutgoingPayment(randomBytes32(), randomKey().publicKey, finalAmount, 200 unixms), 0 msat, None, 100 unixms)),
+ None,
+ 80 unixms)
+ payFsm.send(initiator, paymentSent)
+
+ sender.expectMsg(paymentSent)
+ sender.send(initiator, GetPayment(PaymentIdentifier.PaymentUUID(payment.paymentId)))
+ sender.expectMsg(NoPendingPayment(PaymentIdentifier.PaymentUUID(payment.paymentId)))
+ }
+
test("forward single-part payment when multi-part deactivated", Tag(Tags.DisableMPP)) { f =>
import f._
val finalExpiryDelta = CltvExpiryDelta(24)
### eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/NodeRelayerSpec.scala
@@ -262,6 +262,12 @@ class NodeRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("appl
val failure = FailureReason.LocalFailure(IncorrectOrUnknownPaymentDetails(extra.add.amountMsat, nodeParams.currentBlockHeight))
assert(fwd.message == CMD_FAIL_HTLC(extra.add.id, failure, Some(FailureAttributionData(extraReceivedAt, None)), commit = true))
+ // the extra payment is not included in the downstream payment
+ val outgoingCfg = mockPayFSM.expectMessageType[SendPaymentConfig]
+ val outgoingUpstream = outgoingCfg.upstream.asInstanceOf[Upstream.Hot.Trampoline]
+ assert(outgoingUpstream.received.map(_.add.channelId).toSet == incomingMultiPart.map(_.add.channelId).toSet)
+ mockPayFSM.expectMessageType[SendMultiPartPayment]
+
register.expectNoMessage(100 millis)
}
### eclair-core/src/test/scala/fr/acinq/eclair/payment/send/BlindedPathsResolverSpec.scala
@@ -31,7 +31,7 @@ import fr.acinq.eclair.payment.send.BlindedPathsResolver.{FullBlindedRoute, Part
import fr.acinq.eclair.router.Router.{ChannelHop, HopRelayParams}
import fr.acinq.eclair.router.{BlindedRouteCreation, Router}
import fr.acinq.eclair.wire.protocol.OfferTypes.PaymentInfo
-import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, EncodedNodeId, Features, MilliSatoshiLong, NodeParams, RealShortChannelId, TestConstants, randomBytes32, randomKey}
+import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, EncodedNodeId, MilliSatoshiLong, NodeParams, RealShortChannelId, TestConstants, randomBytes32, randomKey}
import org.scalatest.Outcome
import org.scalatest.funsuite.FixtureAnyFunSuiteLike
import scodec.bits.{ByteVector, HexStringSyntax}
@@ -176,6 +176,25 @@ class BlindedPathsResolverSpec extends ScalaTestWithActorTestKit(ConfigFactory.l
router.expectNoMessage(100 millis)
}
+ test("resolve wallet-encoded route starting at our node") { f =>
+ import f._
+
+ val probe = TestProbe()
+ val walletNodeId = randomKey().publicKey
+ val edge = ExtraEdge(nodeParams.nodeId, walletNodeId, Alias(561), 100 msat, 5, CltvExpiryDelta(144), 1 msat, None)
+ val hop = ChannelHop(edge.shortChannelId, nodeParams.nodeId, walletNodeId, HopRelayParams.FromHint(edge))
+ val route = BlindedRouteCreation.createBlindedRouteToWallet(hop, hex"deadbeef", 1 msat, CltvExpiry(800_000)).route
+ .copy(firstNodeId = EncodedNodeId.WithPublicKey.Wallet(nodeParams.nodeId))
+ val paymentInfo = BlindedRouteCreation.aggregatePaymentInfo(100_000_000 msat, Seq(hop), CltvExpiryDelta(12))
+
+ resolver ! Resolve(probe.ref, Seq(PaymentBlindedRoute(route, paymentInfo)))
+
+ // The wallet encoding must not bypass decryption and local relay fee validation.
+ probe.expectMsg(Seq.empty[ResolvedPath])
+ register.expectNoMessage(100 millis)
+ router.expectNoMessage(100 millis)
+ }
+
test("ignore blinded paths that cannot be resolved") { f =>
import f._
### eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LiquidityAdsSpec.scala
@@ -22,7 +22,7 @@ import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw}
import fr.acinq.eclair.channel.{InvalidLiquidityAdsAmount, InvalidLiquidityAdsSig, MissingLiquidityAds}
import fr.acinq.eclair.{randomBytes32, randomBytes64}
import org.scalatest.funsuite.AnyFunSuite
-import scodec.bits.HexStringSyntax
+import scodec.bits.{ByteVector, HexStringSyntax}
class LiquidityAdsSpec extends AnyFunSuite {
@@ -62,4 +62,11 @@ class LiquidityAdsSpec extends AnyFunSuite {
}
}
+ test("codec round-trip funding rates with no payment types") {
+ // A remote peer can send this valid wire encoding: zero funding rates followed by a 0-length payment-type bitfield.
+ val decoded = LiquidityAds.Codecs.willFundRates.decode(hex"00000000".bits).require.value
+ assert(decoded == LiquidityAds.WillFundRates(Nil, ByteVector.empty))
+ assert(LiquidityAds.Codecs.willFundRates.encode(decoded).isSuccessful)
+ }
+
}Why this scored 64/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.