What changed, and why it matters
This commit hardens the Eclair Lightning node against two related problems when opening payment channels: it prevents the same channel ID from being reused by two different channels at the same time, and it aborts channel creation if the very first database write fails (for example because a channel with that ID already exists from a backup). Before this change, a colliding or partially-restored channel ID could cause one channel's state to overwrite another's, potentially leading to loss of funds or an inconsistent node state.
Deploy this patch to nodes that open or accept Lightning channels. Monitor logs for the new collision warnings and DB-insertion errors, which may indicate backup-restore issues or misbehaving peers. Ensure any partial database restores are reconciled before restarting the node, because the new addChannel() will now reject channels whose IDs already exist.
Security signals we found
State overwrite / collision prevention for channel identifiers
Abort-on-first-DB-write-failure to avoid inconsistent persistence
Global concurrent map guarding temporary and final channel_id usage
New insert-only DB method returning failure instead of silently upserting
Rollback of funding transaction attempt when DB insertion fails
Defense-in-depth: per-peer map check retained alongside global map
Evidence from the diff
The patch introduces a global concurrent TrieMap in NodeParams that tracks every temporary and final channel_id in use. Callers now register an ID before creating the channel actor or transitioning to a final channel_id; if the ID is already present, the open is aborted. The DB layer gains an insert-only addChannel() method that returns an exception on conflict, used for the first persistence of a channel; subsequent updates continue to use addOrUpdateChannel(). Channel actors remove IDs from the map when they terminate, and Peer removes temporary IDs on disconnection. Tests are added for collisions at each channel-opening stage and for DB-level duplicate insertion.
Changed components
NodeParams (global channel_id registry)Channel FSM shutdown / CLOSED state cleanupChannelOpenDualFunded (accept and open paths)ChannelOpenSingleFunded (funding created, funding internal, funding signed paths)ChannelsDb / PgChannelsDb / SqliteChannelsDbPeer actor (initiator and non-initiator spawn paths, disconnection cleanup)Switchboard (initial population of registry from persisted channels)Inspect captured patch +468 / −194
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
index c1a695a..931317c 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
@@ -18,7 +18,7 @@ package fr.acinq.eclair
import com.typesafe.config.{Config, ConfigFactory, ConfigValueType}
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
-import fr.acinq.bitcoin.scalacompat.{Block, BlockHash, Crypto, Satoshi, SatoshiLong}
+import fr.acinq.bitcoin.scalacompat.{Block, BlockHash, ByteVector32, Crypto, Satoshi, SatoshiLong}
import fr.acinq.eclair.Setup.Seeds
import fr.acinq.eclair.blockchain.fee._
import fr.acinq.eclair.channel.fsm.Channel
@@ -71,6 +71,7 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
private val overrideInitFeatures: Map[PublicKey, Features[InitFeature]],
pluginParams: Seq[PluginParams],
channelConf: ChannelConf,
+ private val channelIds: collection.concurrent.TrieMap[ByteVector32, PublicKey],
onChainFeeConf: OnChainFeeConf,
relayParams: RelayParams,
db: Databases,
@@ -118,6 +119,17 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
/** Only to be used in tests. */
def setBitcoinCoreFeerates(value: FeeratesPerKw): Unit = bitcoinCoreFeerates.set(value)
+ /**
+ * Add the channel_id or temporary_channel_id provided to the channels map.
+ * If there is already an entry for this ID, the channels map will not be modified and we return the remote_node_id.
+ * If there was no entry for this ID, we return None.
+ * Callers must check the returned value to ensure that we don't allow conflicts between channel_ids.
+ */
+ def addChannelIdIfAbsent(channelId: ByteVector32, remoteNodeId: PublicKey): Option[PublicKey] = channelIds.putIfAbsent(channelId, remoteNodeId)
+
+ /** Remove the channel_id or temporary_channel_id from the channel map, once the corresponding channel is closed. */
+ def removeChannelId(channelId: ByteVector32): Option[PublicKey] = channelIds.remove(channelId)
+
/** Returns the features that should be used in our init message with the given peer. */
def initFeaturesFor(nodeId: PublicKey): Features[InitFeature] = overrideInitFeatures.getOrElse(nodeId, features).initFeatures()
@@ -607,6 +619,7 @@ object NodeParams extends Logging {
balanceThresholds = config.getConfigList("channel.channel-update.balance-thresholds").asScala.map(conf => BalanceThreshold(Satoshi(conf.getLong("available-sat")), Satoshi(conf.getLong("max-htlc-sat")))).toSeq,
minTimeBetweenUpdates = FiniteDuration(config.getDuration("channel.channel-update.min-time-between-updates").getSeconds, TimeUnit.SECONDS),
),
+ channelIds = collection.concurrent.TrieMap.empty[ByteVector32, PublicKey],
onChainFeeConf = OnChainFeeConf(
feeTargets = feeTargets,
maxClosingFeerate = FeeratePerByte(Satoshi(config.getLong("on-chain-fees.max-closing-feerate"))).perKw,
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
index 6a59ed7..58dbe2a 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
@@ -2380,13 +2380,19 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
when(CLOSED)(handleExceptions {
case Event(Symbol("shutdown"), _) =>
stateData match {
+ case _: TransientChannelData => // nothing was stored in the DB
case d: DATA_CLOSED =>
log.info(s"moving channelId=${d.channelId} to the closed channels DB")
nodeParams.db.channels.removeChannel(d.channelId, Some(d))
- case _: PersistentChannelData | _: IgnoreClosedData =>
+ case _: PersistentChannelData =>
log.info("deleting database record for channelId={}", stateData.channelId)
nodeParams.db.channels.removeChannel(stateData.channelId, None)
- case _: TransientChannelData => // nothing was stored in the DB
+ case IgnoreClosedData(d) => d match {
+ case _: PersistentChannelData =>
+ log.info("deleting database record for channelId={}", stateData.channelId)
+ nodeParams.db.channels.removeChannel(stateData.channelId, None)
+ case _ => // nothing was stored in the DB
+ }
}
log.info("shutting down")
stop(FSM.Normal)
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala
index 78fdce5..74d1091 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala
@@ -144,7 +144,6 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
Helpers.validateParamsDualFundedNonInitiator(nodeParams, open, fundingScript, remoteNodeId, d.init.localChannelParams.initFeatures, d.init.remoteInit.features, d.init.fundingContribution_opt) match {
case Left(t) => handleLocalError(t, d, Some(open))
case Right((channelFeatures, remoteShutdownScript, willFund_opt)) =>
- context.system.eventStream.publish(ChannelCreated(self, peer, remoteNodeId, isOpener = false, open.temporaryChannelId, open.commitmentFeerate, Some(open.fundingFeerate)))
val remoteChannelParams = RemoteChannelParams(
nodeId = remoteNodeId,
initialRequestedChannelReserve_opt = None, // channel reserve will be computed based on channel capacity
@@ -185,34 +184,41 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
firstPerCommitmentPoint = channelKeys.commitmentPoint(0),
secondPerCommitmentPoint = channelKeys.commitmentPoint(1),
tlvStream = TlvStream(tlvs))
- peer ! ChannelIdAssigned(self, remoteNodeId, accept.temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages
- txPublisher ! SetChannelId(remoteNodeId, channelId)
- context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, accept.temporaryChannelId, channelId))
- // We start the interactive-tx funding protocol.
- val fundingParams = InteractiveTxParams(
- channelId = channelId,
- isInitiator = d.init.localChannelParams.isChannelOpener,
- localContribution = accept.fundingAmount,
- remoteContribution = open.fundingAmount,
- sharedInput_opt = None,
- remoteFundingPubKey = open.fundingPubkey,
- localOutputs = Nil,
- commitmentFormat = d.init.channelType.commitmentFormat,
- lockTime = open.lockTime,
- dustLimit = open.dustLimit.max(accept.dustLimit),
- targetFeerate = open.fundingFeerate,
- requireConfirmedInputs = RequireConfirmedInputs(forLocal = open.requireConfirmedInputs, forRemote = accept.requireConfirmedInputs)
- )
- val purpose = InteractiveTxBuilder.FundingTx(open.commitmentFeerate, open.firstPerCommitmentPoint, feeBudget_opt = None)
- val txBuilder = context.spawnAnonymous(InteractiveTxBuilder(
- randomBytes32(),
- nodeParams, fundingParams,
- channelParams, localCommitParams, remoteCommitParams, channelKeys, purpose,
- localPushAmount = accept.pushAmount, remotePushAmount = open.pushAmount,
- willFund_opt.map(_.purchase),
- wallet))
- txBuilder ! InteractiveTxBuilder.Start(self)
- goto(WAIT_FOR_DUAL_FUNDING_CREATED) using DATA_WAIT_FOR_DUAL_FUNDING_CREATED(channelId, channelParams, localCommitParams, remoteCommitParams, open.secondPerCommitmentPoint, accept.pushAmount, open.pushAmount, txBuilder, deferred = None, replyTo_opt = None) sending accept
+ nodeParams.addChannelIdIfAbsent(channelId, remoteNodeId) match {
+ case None =>
+ context.system.eventStream.publish(ChannelCreated(self, peer, remoteNodeId, isOpener = false, open.temporaryChannelId, open.commitmentFeerate, Some(open.fundingFeerate)))
+ peer ! ChannelIdAssigned(self, remoteNodeId, accept.temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages
+ txPublisher ! SetChannelId(remoteNodeId, channelId)
+ context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, accept.temporaryChannelId, channelId))
+ // We start the interactive-tx funding protocol.
+ val fundingParams = InteractiveTxParams(
+ channelId = channelId,
+ isInitiator = d.init.localChannelParams.isChannelOpener,
+ localContribution = accept.fundingAmount,
+ remoteContribution = open.fundingAmount,
+ sharedInput_opt = None,
+ remoteFundingPubKey = open.fundingPubkey,
+ localOutputs = Nil,
+ commitmentFormat = d.init.channelType.commitmentFormat,
+ lockTime = open.lockTime,
+ dustLimit = open.dustLimit.max(accept.dustLimit),
+ targetFeerate = open.fundingFeerate,
+ requireConfirmedInputs = RequireConfirmedInputs(forLocal = open.requireConfirmedInputs, forRemote = accept.requireConfirmedInputs)
+ )
+ val purpose = InteractiveTxBuilder.FundingTx(open.commitmentFeerate, open.firstPerCommitmentPoint, feeBudget_opt = None)
+ val txBuilder = context.spawnAnonymous(InteractiveTxBuilder(
+ randomBytes32(),
+ nodeParams, fundingParams,
+ channelParams, localCommitParams, remoteCommitParams, channelKeys, purpose,
+ localPushAmount = accept.pushAmount, remotePushAmount = open.pushAmount,
+ willFund_opt.map(_.purchase),
+ wallet))
+ txBuilder ! InteractiveTxBuilder.Start(self)
+ goto(WAIT_FOR_DUAL_FUNDING_CREATED) using DATA_WAIT_FOR_DUAL_FUNDING_CREATED(channelId, channelParams, localCommitParams, remoteCommitParams, open.secondPerCommitmentPoint, accept.pushAmount, open.pushAmount, txBuilder, deferred = None, replyTo_opt = None) sending accept
+ case Some(nodeId) =>
+ log.warning("channel_id={} already used with remoteNodeId={}: aborting remote channel open", channelId, nodeId)
+ handleLocalError(InvalidFundingTx(channelId), d, Some(open))
+ }
}
case Event(c: CloseCommand, d) => handleFastClose(c, d.channelId)
@@ -231,48 +237,55 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
case Right((channelFeatures, remoteShutdownScript, liquidityPurchase_opt)) =>
// We've exchanged open_channel2 and accept_channel2, we now know the final channelId.
val channelId = Helpers.computeChannelId(d.lastSent.revocationBasepoint, accept.revocationBasepoint)
- peer ! ChannelIdAssigned(self, remoteNodeId, accept.temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages
- txPublisher ! SetChannelId(remoteNodeId, channelId)
- context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, accept.temporaryChannelId, channelId))
- val remoteChannelParams = RemoteChannelParams(
- nodeId = remoteNodeId,
- initialRequestedChannelReserve_opt = None, // channel reserve will be computed based on channel capacity
- revocationBasepoint = accept.revocationBasepoint,
- paymentBasepoint = accept.paymentBasepoint,
- delayedPaymentBasepoint = accept.delayedPaymentBasepoint,
- htlcBasepoint = accept.htlcBasepoint,
- initFeatures = d.init.remoteInit.features,
- upfrontShutdownScript_opt = remoteShutdownScript)
- // We start the interactive-tx funding protocol.
- val channelParams = ChannelParams(channelId, d.init.channelConfig, channelFeatures, d.init.localChannelParams, remoteChannelParams, d.lastSent.channelFlags)
- val localCommitParams = CommitParams(d.init.proposedCommitParams.localDustLimit, d.init.proposedCommitParams.localHtlcMinimum, d.init.proposedCommitParams.localMaxHtlcValueInFlight, d.init.proposedCommitParams.localMaxAcceptedHtlcs, accept.toSelfDelay)
- val remoteCommitParams = CommitParams(accept.dustLimit, accept.htlcMinimum, accept.maxHtlcValueInFlightMsat, accept.maxAcceptedHtlcs, d.init.proposedCommitParams.toRemoteDelay)
- val localAmount = d.lastSent.fundingAmount
- val remoteAmount = accept.fundingAmount
- val fundingParams = InteractiveTxParams(
- channelId = channelId,
- isInitiator = d.init.localChannelParams.isChannelOpener,
- localContribution = localAmount,
- remoteContribution = remoteAmount,
- sharedInput_opt = None,
- remoteFundingPubKey = accept.fundingPubkey,
- localOutputs = Nil,
- commitmentFormat = d.init.channelType.commitmentFormat,
- lockTime = d.lastSent.lockTime,
- dustLimit = d.lastSent.dustLimit.max(accept.dustLimit),
- targetFeerate = d.lastSent.fundingFeerate,
- requireConfirmedInputs = RequireConfirmedInputs(forLocal = accept.requireConfirmedInputs, forRemote = d.lastSent.requireConfirmedInputs)
- )
- val purpose = InteractiveTxBuilder.FundingTx(d.lastSent.commitmentFeerate, accept.firstPerCommitmentPoint, feeBudget_opt = d.init.fundingTxFeeBudget_opt)
- val txBuilder = context.spawnAnonymous(InteractiveTxBuilder(
- randomBytes32(),
- nodeParams, fundingParams,
- channelParams, localCommitParams, remoteCommitParams, channelKeys, purpose,
- localPushAmount = d.lastSent.pushAmount, remotePushAmount = accept.pushAmount,
- liquidityPurchase_opt = liquidityPurchase_opt,
- wallet))
- txBuilder ! InteractiveTxBuilder.Start(self)
- goto(WAIT_FOR_DUAL_FUNDING_CREATED) using DATA_WAIT_FOR_DUAL_FUNDING_CREATED(channelId, channelParams, localCommitParams, remoteCommitParams, accept.secondPerCommitmentPoint, d.lastSent.pushAmount, accept.pushAmount, txBuilder, deferred = None, replyTo_opt = Some(d.init.replyTo))
+ nodeParams.addChannelIdIfAbsent(channelId, remoteNodeId) match {
+ case None =>
+ peer ! ChannelIdAssigned(self, remoteNodeId, accept.temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages
+ txPublisher ! SetChannelId(remoteNodeId, channelId)
+ context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, accept.temporaryChannelId, channelId))
+ val remoteChannelParams = RemoteChannelParams(
+ nodeId = remoteNodeId,
+ initialRequestedChannelReserve_opt = None, // channel reserve will be computed based on channel capacity
+ revocationBasepoint = accept.revocationBasepoint,
+ paymentBasepoint = accept.paymentBasepoint,
+ delayedPaymentBasepoint = accept.delayedPaymentBasepoint,
+ htlcBasepoint = accept.htlcBasepoint,
+ initFeatures = d.init.remoteInit.features,
+ upfrontShutdownScript_opt = remoteShutdownScript)
+ // We start the interactive-tx funding protocol.
+ val channelParams = ChannelParams(channelId, d.init.channelConfig, channelFeatures, d.init.localChannelParams, remoteChannelParams, d.lastSent.channelFlags)
+ val localCommitParams = CommitParams(d.init.proposedCommitParams.localDustLimit, d.init.proposedCommitParams.localHtlcMinimum, d.init.proposedCommitParams.localMaxHtlcValueInFlight, d.init.proposedCommitParams.localMaxAcceptedHtlcs, accept.toSelfDelay)
+ val remoteCommitParams = CommitParams(accept.dustLimit, accept.htlcMinimum, accept.maxHtlcValueInFlightMsat, accept.maxAcceptedHtlcs, d.init.proposedCommitParams.toRemoteDelay)
+ val localAmount = d.lastSent.fundingAmount
+ val remoteAmount = accept.fundingAmount
+ val fundingParams = InteractiveTxParams(
+ channelId = channelId,
+ isInitiator = d.init.localChannelParams.isChannelOpener,
+ localContribution = localAmount,
+ remoteContribution = remoteAmount,
+ sharedInput_opt = None,
+ remoteFundingPubKey = accept.fundingPubkey,
+ localOutputs = Nil,
+ commitmentFormat = d.init.channelType.commitmentFormat,
+ lockTime = d.lastSent.lockTime,
+ dustLimit = d.lastSent.dustLimit.max(accept.dustLimit),
+ targetFeerate = d.lastSent.fundingFeerate,
+ requireConfirmedInputs = RequireConfirmedInputs(forLocal = accept.requireConfirmedInputs, forRemote = d.lastSent.requireConfirmedInputs)
+ )
+ val purpose = InteractiveTxBuilder.FundingTx(d.lastSent.commitmentFeerate, accept.firstPerCommitmentPoint, feeBudget_opt = d.init.fundingTxFeeBudget_opt)
+ val txBuilder = context.spawnAnonymous(InteractiveTxBuilder(
+ randomBytes32(),
+ nodeParams, fundingParams,
+ channelParams, localCommitParams, remoteCommitParams, channelKeys, purpose,
+ localPushAmount = d.lastSent.pushAmount, remotePushAmount = accept.pushAmount,
+ liquidityPurchase_opt = liquidityPurchase_opt,
+ wallet))
+ txBuilder ! InteractiveTxBuilder.Start(self)
+ goto(WAIT_FOR_DUAL_FUNDING_CREATED) using DATA_WAIT_FOR_DUAL_FUNDING_CREATED(channelId, channelParams, localCommitParams, remoteCommitParams, accept.secondPerCommitmentPoint, d.lastSent.pushAmount, accept.pushAmount, txBuilder, deferred = None, replyTo_opt = Some(d.init.replyTo))
+ case Some(nodeId) =>
+ log.warning("channel_id={} already used with remoteNodeId={}: aborting local channel open", channelId, nodeId)
+ d.init.replyTo ! OpenChannelResponse.Rejected("channel_id conflict")
+ handleLocalError(InvalidFundingTx(channelId), d, Some(accept))
+ }
}
case Event(c: CloseCommand, d: DATA_WAIT_FOR_ACCEPT_DUAL_FUNDED_CHANNEL) =>
@@ -324,13 +337,20 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
case InteractiveTxBuilder.SendMessage(_, msg) => stay() sending msg
case InteractiveTxBuilder.Succeeded(status, commitSig, liquidityPurchase_opt, nextRemoteCommitNonce_opt) =>
nextRemoteCommitNonce_opt.foreach { case (txId, nonce) => remoteNextCommitNonces = remoteNextCommitNonces + (txId -> nonce) }
- d.deferred.foreach(self ! _)
- d.replyTo_opt.foreach(_ ! OpenChannelResponse.Created(d.channelId, status.fundingTx.txId, status.fundingTx.tx.localFees.truncateToSatoshi))
- liquidityPurchase_opt.collect {
- case purchase if !status.fundingParams.isInitiator => peer ! LiquidityPurchaseSigned(d.channelId, status.fundingTx.txId, status.fundingTxIndex, d.remoteCommitParams.htlcMinimum, purchase)
- }
val d1 = DATA_WAIT_FOR_DUAL_FUNDING_SIGNED(d.channelParams, d.secondRemotePerCommitmentPoint, d.localPushAmount, d.remotePushAmount, status)
- goto(WAIT_FOR_DUAL_FUNDING_SIGNED) using d1 storing() sending commitSig
+ nodeParams.db.channels.addChannel(d1) match {
+ case None =>
+ d.deferred.foreach(self ! _)
+ d.replyTo_opt.foreach(_ ! OpenChannelResponse.Created(d.channelId, status.fundingTx.txId, status.fundingTx.tx.localFees.truncateToSatoshi))
+ liquidityPurchase_opt.collect {
+ case purchase if !status.fundingParams.isInitiator => peer ! LiquidityPurchaseSigned(d.channelId, status.fundingTx.txId, status.fundingTxIndex, d.remoteCommitParams.htlcMinimum, purchase)
+ }
+ goto(WAIT_FOR_DUAL_FUNDING_SIGNED) using d1 sending commitSig
+ case Some(t) =>
+ d.replyTo_opt.foreach(_ ! OpenChannelResponse.Rejected(t.getMessage))
+ rollbackFundingAttempt(status.fundingTx.tx, Nil)
+ goto(CLOSED) using IgnoreClosedData(d) sending TxAbort(d.channelId, InvalidFundingTx(d.channelId).getMessage)
+ }
case f: InteractiveTxBuilder.Failed =>
d.replyTo_opt.foreach(_ ! OpenChannelResponse.Rejected(f.cause.getMessage))
goto(CLOSED) using IgnoreClosedData(d) sending TxAbort(d.channelId, f.cause.getMessage)
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala
index 69a4d2e..2439099 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala
@@ -241,11 +241,18 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers {
val fundingCreated = FundingCreated(temporaryChannelId, fundingTx.txid, fundingTxOutputIndex, localSig)
val channelId = toLongId(fundingTx.txid, fundingTxOutputIndex)
val channelParams1 = d.channelParams.copy(channelId = channelId)
- peer ! ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages
- txPublisher ! SetChannelId(remoteNodeId, channelId)
- context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId))
- // NB: we don't send a ChannelSignatureSent for the first commit
- goto(WAIT_FOR_FUNDING_SIGNED) using DATA_WAIT_FOR_FUNDING_SIGNED(channelParams1, d.channelType, d.localCommitParams, d.remoteCommitParams, d.remoteFundingPubKey, fundingTx, fundingTxFee, localSpec, localCommitTx, remoteCommit, fundingCreated, d.replyTo) sending fundingCreated
+ nodeParams.addChannelIdIfAbsent(channelId, remoteNodeId) match {
+ case None =>
+ peer ! ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages
+ txPublisher ! SetChannelId(remoteNodeId, channelId)
+ context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId))
+ // NB: we don't send a ChannelSignatureSent for the first commit
+ goto(WAIT_FOR_FUNDING_SIGNED) using DATA_WAIT_FOR_FUNDING_SIGNED(channelParams1, d.channelType, d.localCommitParams, d.remoteCommitParams, d.remoteFundingPubKey, fundingTx, fundingTxFee, localSpec, localCommitTx, remoteCommit, fundingCreated, d.replyTo) sending fundingCreated
+ case Some(nodeId) =>
+ log.warning("channel_id={} already used with remoteNodeId={}: aborting local channel open", channelId, nodeId)
+ d.replyTo ! OpenChannelResponse.Rejected("channel_id conflict")
+ handleLocalError(InvalidFundingTx(channelId), d, None)
+ }
}
}
@@ -330,15 +337,30 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers {
remoteNextCommitInfo = Right(randomKey().publicKey), // we will receive their next per-commitment point in the next message, so we temporarily put a random byte array
remotePerCommitmentSecrets = ShaChain.init,
originChannels = Map.empty)
- peer ! ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages
- txPublisher ! SetChannelId(remoteNodeId, channelId)
- context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId))
- context.system.eventStream.publish(ChannelSignatureReceived(self, commitments))
- context.system.eventStream.publish(ChannelFundingCreated(self, channelId, remoteNodeId, Left(commitment.fundingTxId), commitment.fundingTxIndex, commitments))
- // NB: we don't send a ChannelSignatureSent for the first commit
- log.info("waiting for them to publish the funding tx for channelId={} fundingTxid={}", channelId, commitment.fundingTxId)
- watchFundingConfirmed(commitment.fundingTxId, d.channelParams.minDepth(nodeParams.channelConf.minDepth), delay_opt = None)
- goto(WAIT_FOR_FUNDING_CONFIRMED) using DATA_WAIT_FOR_FUNDING_CONFIRMED(commitments, nodeParams.currentBlockHeight, None, Right(fundingSigned)) storing() sending fundingSigned
+ val d1 = DATA_WAIT_FOR_FUNDING_CONFIRMED(commitments, nodeParams.currentBlockHeight, None, Right(fundingSigned))
+ nodeParams.addChannelIdIfAbsent(channelId, remoteNodeId) match {
+ case None =>
+ nodeParams.db.channels.addChannel(d1) match {
+ case None =>
+ peer ! ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages
+ txPublisher ! SetChannelId(remoteNodeId, channelId)
+ context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId))
+ context.system.eventStream.publish(ChannelSignatureReceived(self, commitments))
+ context.system.eventStream.publish(ChannelFundingCreated(self, channelId, remoteNodeId, Left(commitment.fundingTxId), commitment.fundingTxIndex, commitments))
+ // NB: we don't send a ChannelSignatureSent for the first commit
+ log.info("waiting for them to publish the funding tx for channelId={} fundingTxid={}", channelId, commitment.fundingTxId)
+ watchFundingConfirmed(commitment.fundingTxId, d.channelParams.minDepth(nodeParams.channelConf.minDepth), delay_opt = None)
+ goto(WAIT_FOR_FUNDING_CONFIRMED) using d1 sending fundingSigned
+ case Some(t) =>
+ log.error(s"cannot add channel to DB: ${t.getMessage}")
+ val error = Error(channelId, InvalidFundingTx(channelId).getMessage)
+ goto(CLOSED) using IgnoreClosedData(d) sending error
+ }
+ case Some(nodeId) =>
+ log.warning("channel_id={} already used with remoteNodeId={}: aborting remote channel open", channelId, nodeId)
+ val error = Error(channelId, InvalidFundingTx(channelId).getMessage)
+ goto(CLOSED) using IgnoreClosedData(d) sending error
+ }
}
}
}
@@ -391,13 +413,22 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers {
remotePerCommitmentSecrets = ShaChain.init,
originChannels = Map.empty)
val blockHeight = nodeParams.currentBlockHeight
- context.system.eventStream.publish(ChannelSignatureReceived(self, commitments))
- context.system.eventStream.publish(ChannelFundingCreated(self, d.channelId, remoteNodeId, Right(d.fundingTx), commitment.fundingTxIndex, commitments))
- log.info("publishing funding tx fundingTxId={}", commitment.fundingTxId)
- watchFundingConfirmed(commitment.fundingTxId, d.channelParams.minDepth(nodeParams.channelConf.minDepth), delay_opt = None)
- // we will publish the funding tx only after the channel state has been written to disk because we want to
- // make sure we first persist the commitment that returns back the funds to us in case of problem
- goto(WAIT_FOR_FUNDING_CONFIRMED) using DATA_WAIT_FOR_FUNDING_CONFIRMED(commitments, blockHeight, None, Left(d.lastSent)) storing() calling publishFundingTx(d.channelId, d.fundingTx, d.fundingTxFee, d.replyTo)
+ val d1 = DATA_WAIT_FOR_FUNDING_CONFIRMED(commitments, blockHeight, None, Left(d.lastSent))
+ nodeParams.db.channels.addChannel(d1) match {
+ case None =>
+ context.system.eventStream.publish(ChannelSignatureReceived(self, commitments))
+ context.system.eventStream.publish(ChannelFundingCreated(self, d.channelId, remoteNodeId, Right(d.fundingTx), commitment.fundingTxIndex, commitments))
+ log.info("publishing funding tx fundingTxId={}", commitment.fundingTxId)
+ watchFundingConfirmed(commitment.fundingTxId, d.channelParams.minDepth(nodeParams.channelConf.minDepth), delay_opt = None)
+ // we will publish the funding tx only after the channel state has been written to disk because we want to
+ // make sure we first persist the commitment that returns back the funds to us in case of problem
+ goto(WAIT_FOR_FUNDING_CONFIRMED) using d1 calling publishFundingTx(d.channelId, d.fundingTx, d.fundingTxFee, d.replyTo)
+ case Some(t) =>
+ log.error(s"cannot add channel to DB: ${t.getMessage}")
+ wallet.rollback(d.fundingTx)
+ d.replyTo ! OpenChannelResponse.Rejected(s"cannot add channel to DB: ${t.getMessage}")
+ handleLocalError(InvalidFundingTx(d1.channelId), d, Some(fundingSigned))
+ }
}
case Event(c: CloseCommand, d: DATA_WAIT_FOR_FUNDING_SIGNED) =>
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/ChannelsDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/ChannelsDb.scala
index 71c8ed8..240b70b 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/db/ChannelsDb.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/ChannelsDb.scala
@@ -24,6 +24,9 @@ import fr.acinq.eclair.{CltvExpiry, Paginated}
trait ChannelsDb {
+ /** Returns an exception if the channel already exists in the DB or cannot be added. */
+ def addChannel(data: PersistentChannelData): Option[Throwable]
+
def addOrUpdateChannel(data: PersistentChannelData): Unit
def getChannel(channelId: ByteVector32): Option[PersistentChannelData]
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgChannelsDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgChannelsDb.scala
index 43f3d76..a4a2966 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgChannelsDb.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgChannelsDb.scala
@@ -87,6 +87,30 @@ class PgChannelsDb(implicit ds: DataSource, lock: PgLock) extends ChannelsDb wit
)(logger)
}
+ override def addChannel(data: PersistentChannelData): Option[Throwable] = withMetrics("channels/add-channel", DbBackends.Postgres) {
+ try {
+ withLock { pg =>
+ val encoded = channelDataCodec.encode(data).require.toByteArray
+ using(pg.prepareStatement(
+ """
+ | INSERT INTO local.channels (channel_id, remote_node_id, data, json, created_timestamp, last_connected_timestamp)
+ | VALUES (?, ?, ?, ?::JSONB, ?, ?)
+ | """.stripMargin)) { statement =>
+ statement.setString(1, data.channelId.toHex)
+ statement.setString(2, data.remoteNodeId.toHex)
+ statement.setBytes(3, encoded)
+ statement.setString(4, serialization.write(data))
+ statement.setTimestamp(5, Timestamp.from(Instant.now()))
+ statement.setTimestamp(6, Timestamp.from(Instant.now()))
+ statement.executeUpdate()
+ }
+ None
+ }
+ } catch {
+ case t: Throwable => Some(t)
+ }
+ }
+
override def addOrUpdateChannel(data: PersistentChannelData): Unit = withMetrics("channels/add-or-update-channel", DbBackends.Postgres) {
withLock { pg =>
val encoded = channelDataCodec.encode(data).require.toByteArray
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteChannelsDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteChannelsDb.scala
index f2bb46b..14b00b8 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteChannelsDb.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteChannelsDb.scala
@@ -67,6 +67,22 @@ class SqliteChannelsDb(val sqlite: Connection) extends ChannelsDb with Logging {
setVersion(statement, DB_NAME, CURRENT_VERSION)
}
+ override def addChannel(data: PersistentChannelData): Option[Throwable] = withMetrics("channels/add-channel", DbBackends.Sqlite) {
+ try {
+ val encoded = channelDataCodec.encode(data).require.toByteArray
+ using(sqlite.prepareStatement("INSERT INTO local_channels (channel_id, data, created_timestamp, last_connected_timestamp) VALUES (?, ?, ?, ?)")) { statement =>
+ statement.setBytes(1, data.channelId.toArray)
+ statement.setBytes(2, encoded)
+ statement.setLong(3, TimestampMilli.now().toLong)
+ statement.setLong(4, TimestampMilli.now().toLong)
+ statement.executeUpdate()
+ }
+ None
+ } catch {
+ case t: Throwable => Some(t)
+ }
+ }
+
override def addOrUpdateChannel(data: PersistentChannelData): Unit = withMetrics("channels/add-or-update-channel", DbBackends.Sqlite) {
val encoded = channelDataCodec.encode(data).require.toByteArray
using(sqlite.prepareStatement("UPDATE local_channels SET data=? WHERE channel_id=?")) { update =>
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
index 794a987..f7e57c9 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
@@ -108,6 +108,7 @@ class Peer(val nodeParams: NodeParams,
// we have at most 2 ids: a TemporaryChannelId and a FinalChannelId
val channelIds = d.channels.filter(_._2 == actor).keys
log.info(s"channel closed: channelId=${channelIds.mkString("/")}")
+ channelIds.foreach(channelId => nodeParams.removeChannelId(channelId.id))
d.channels -- channelIds
} else {
d.channels
@@ -206,37 +207,45 @@ class Peer(val nodeParams: NodeParams,
case Event(SpawnChannelInitiator(replyTo, c, channelConfig, channelType, localParams), d: ConnectedData) =>
val channelKeys = nodeParams.channelKeyManager.channelKeys(channelConfig, localParams.fundingKeyPath)
- val channel = spawnChannel(channelKeys)
- context.system.scheduler.scheduleOnce(c.timeout_opt.map(_.duration).getOrElse(nodeParams.channelConf.channelFundingTimeout), channel, Channel.TickChannelOpenTimeout)(context.dispatcher)
val dualFunded = Features.canUseFeature(d.localFeatures, d.remoteFeatures, Features.DualFunding)
- val requireConfirmedInputs = c.requireConfirmedInputsOverride_opt.getOrElse(nodeParams.channelConf.requireConfirmedInputsForDualFunding)
val temporaryChannelId = if (dualFunded) {
Helpers.dualFundedTemporaryChannelId(channelKeys)
} else {
randomBytes32()
}
- val init = INPUT_INIT_CHANNEL_INITIATOR(
- temporaryChannelId = temporaryChannelId,
- fundingAmount = c.fundingAmount,
- dualFunded = dualFunded,
- commitTxFeerate = nodeParams.onChainFeeConf.getCommitmentFeerate(nodeParams.currentBitcoinCoreFeerates, remoteNodeId, channelType.commitmentFormat),
- fundingTxFeerate = c.fundingTxFeerate_opt.getOrElse(nodeParams.onChainFeeConf.getFundingFeerate(nodeParams.currentFeeratesForFundingClosing)),
- fundingTxFeeBudget_opt = c.fundingTxFeeBudget_opt,
- pushAmount_opt = c.pushAmount_opt,
- requireConfirmedInputs = requireConfirmedInputs,
- requestFunding_opt = c.requestFunding_opt,
- localChannelParams = localParams,
- proposedCommitParams = nodeParams.channelConf.commitParams(c.fundingAmount, channelType, unlimitedMaxHtlcValueInFlight = false),
- remote = d.peerConnection,
- remoteInit = d.remoteInit,
- channelFlags = c.channelFlags_opt.getOrElse(nodeParams.channelConf.channelFlags),
- channelConfig = channelConfig,
- channelType = channelType,
- replyTo = replyTo
- )
- log.info(s"requesting a new channel with type=$channelType fundingAmount=${c.fundingAmount} dualFunded=$dualFunded pushAmount=${c.pushAmount_opt} fundingFeerate=${init.fundingTxFeerate} temporaryChannelId=$temporaryChannelId")
- channel ! init
- stay() using d.copy(channels = d.channels + (TemporaryChannelId(temporaryChannelId) -> channel))
+ // We first check that this temporary_channel_id isn't already being used.
+ nodeParams.addChannelIdIfAbsent(temporaryChannelId, remoteNodeId) match {
+ case None =>
+ val channel = spawnChannel(channelKeys)
+ context.system.scheduler.scheduleOnce(c.timeout_opt.map(_.duration).getOrElse(nodeParams.channelConf.channelFundingTimeout), channel, Channel.TickChannelOpenTimeout)(context.dispatcher)
+ val requireConfirmedInputs = c.requireConfirmedInputsOverride_opt.getOrElse(nodeParams.channelConf.requireConfirmedInputsForDualFunding)
+ val init = INPUT_INIT_CHANNEL_INITIATOR(
+ temporaryChannelId = temporaryChannelId,
+ fundingAmount = c.fundingAmount,
+ dualFunded = dualFunded,
+ commitTxFeerate = nodeParams.onChainFeeConf.getCommitmentFeerate(nodeParams.currentBitcoinCoreFeerates, remoteNodeId, channelType.commitmentFormat),
+ fundingTxFeerate = c.fundingTxFeerate_opt.getOrElse(nodeParams.onChainFeeConf.getFundingFeerate(nodeParams.currentFeeratesForFundingClosing)),
+ fundingTxFeeBudget_opt = c.fundingTxFeeBudget_opt,
+ pushAmount_opt = c.pushAmount_opt,
+ requireConfirmedInputs = requireConfirmedInputs,
+ requestFunding_opt = c.requestFunding_opt,
+ localChannelParams = localParams,
+ proposedCommitParams = nodeParams.channelConf.commitParams(c.fundingAmount, channelType, unlimitedMaxHtlcValueInFlight = false),
+ remote = d.peerConnection,
+ remoteInit = d.remoteInit,
+ channelFlags = c.channelFlags_opt.getOrElse(nodeParams.channelConf.channelFlags),
+ channelConfig = channelConfig,
+ channelType = channelType,
+ replyTo = replyTo
+ )
+ log.info(s"requesting a new channel with type=$channelType fundingAmount=${c.fundingAmount} dualFunded=$dualFunded pushAmount=${c.pushAmount_opt} fundingFeerate=${init.fundingTxFeerate} temporaryChannelId=$temporaryChannelId")
+ channel ! init
+ stay() using d.copy(channels = d.channels + (TemporaryChannelId(temporaryChannelId) -> channel))
+ case Some(nodeId) =>
+ log.warning("temporary_channel_id={} already used with remoteNodeId={}: aborting local channel open", temporaryChannelId, nodeId)
+ replyTo ! OpenChannelResponse.Rejected("temporary_channel_id conflict")
+ stay()
+ }
case Event(open: protocol.OpenChannel, d: ConnectedData) =>
d.channels.get(TemporaryChannelId(open.temporaryChannelId)).orElse(d.channels.get(FinalChannelId(open.temporaryChannelId))) match {
@@ -268,66 +277,76 @@ class Peer(val nodeParams: NodeParams,
case Event(SpawnChannelNonInitiator(open, channelConfig, channelType, addFunding_opt, localParams, peerConnection), d: ConnectedData) =>
val temporaryChannelId = open.fold(_.temporaryChannelId, _.temporaryChannelId)
- // Since the channel interceptor step isn't atomic, we must check again that there is no duplicate/conflict.
- d.channels.get(TemporaryChannelId(temporaryChannelId)).orElse(d.channels.get(FinalChannelId(temporaryChannelId))) match {
- case Some(_) =>
- log.warning("ignoring open_channel with duplicate temporaryChannelId={}", temporaryChannelId)
- stay()
- case None if peerConnection != d.peerConnection =>
- log.warning("ignoring open_channel request that reconnected during channel intercept, temporaryChannelId={}", temporaryChannelId)
- context.system.eventStream.publish(ChannelAborted(ActorRef.noSender, remoteNodeId, temporaryChannelId))
- stay()
+ // We first check that this temporary_channel_id isn't already being used.
+ nodeParams.addChannelIdIfAbsent(temporaryChannelId, remoteNodeId) match {
case None =>
- OnTheFlyFunding.validateOpen(nodeParams.onTheFlyFundingConfig, remoteNodeId, open, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
- case reject: OnTheFlyFunding.ValidationResult.Reject =>
- log.warning("rejecting on-the-fly channel: {}", reject.cancel.toAscii)
- self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection)
- cancelUnsignedOnTheFlyFunding(reject.paymentHashes)
+ // We also check our per-peer mapping for defense in-depth.
+ d.channels.get(TemporaryChannelId(temporaryChannelId)).orElse(d.channels.get(FinalChannelId(temporaryChannelId))) match {
+ case Some(_) =>
+ log.warning("ignoring open_channel with duplicate temporaryChannelId={}", temporaryChannelId)
+ stay()
+ case None if peerConnection != d.peerConnection =>
+ log.warning("ignoring open_channel request that reconnected during channel intercept, temporaryChannelId={}", temporaryChannelId)
context.system.eventStream.publish(ChannelAborted(ActorRef.noSender, remoteNodeId, temporaryChannelId))
+ nodeParams.removeChannelId(temporaryChannelId)
stay()
- case accept: OnTheFlyFunding.ValidationResult.Accept =>
- val channelKeys = nodeParams.channelKeyManager.channelKeys(channelConfig, localParams.fundingKeyPath)
- val channel = spawnChannel(channelKeys)
- context.system.scheduler.scheduleOnce(nodeParams.channelConf.channelFundingTimeout, channel, Channel.TickChannelOpenTimeout)(context.dispatcher)
- log.info(s"accepting a new channel with type=$channelType temporaryChannelId=$temporaryChannelId localParams=$localParams")
- open match {
- case Left(open) =>
- val init = INPUT_INIT_CHANNEL_NON_INITIATOR(
- temporaryChannelId = open.temporaryChannelId,
- fundingContribution_opt = None,
- dualFunded = false,
- pushAmount_opt = None,
- requireConfirmedInputs = false,
- localChannelParams = localParams,
- proposedCommitParams = nodeParams.channelConf.commitParams(open.fundingSatoshis, channelType, unlimitedMaxHtlcValueInFlight = false),
- remote = d.peerConnection,
- remoteInit = d.remoteInit,
- channelConfig = channelConfig,
- channelType = channelType)
- channel ! init
- channel ! open
- case Right(open) =>
- val init = INPUT_INIT_CHANNEL_NON_INITIATOR(
- temporaryChannelId = open.temporaryChannelId,
- fundingContribution_opt = addFunding_opt,
- dualFunded = true,
- pushAmount_opt = None,
- requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding,
- localChannelParams = localParams,
- proposedCommitParams = nodeParams.channelConf.commitParams(open.fundingAmount + addFunding_opt.map(_.fundingAmount).getOrElse(0 sat), channelType, unlimitedMaxHtlcValueInFlight = false),
- remote = d.peerConnection,
- remoteInit = d.remoteInit,
- channelConfig = channelConfig,
- channelType = channelType)
- channel ! init
- accept.useFeeCredit_opt match {
- case Some(useFeeCredit) => channel ! open.copy(tlvStream = TlvStream(open.tlvStream.records + ChannelTlv.UseFeeCredit(useFeeCredit)))
- case None => channel ! open
+ case None =>
+ OnTheFlyFunding.validateOpen(nodeParams.onTheFlyFundingConfig, remoteNodeId, open, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
+ case reject: OnTheFlyFunding.ValidationResult.Reject =>
+ log.warning("rejecting on-the-fly channel: {}", reject.cancel.toAscii)
+ self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection)
+ cancelUnsignedOnTheFlyFunding(reject.paymentHashes)
+ context.system.eventStream.publish(ChannelAborted(ActorRef.noSender, remoteNodeId, temporaryChannelId))
+ nodeParams.removeChannelId(temporaryChannelId)
+ stay()
+ case accept: OnTheFlyFunding.ValidationResult.Accept =>
+ val channelKeys = nodeParams.channelKeyManager.channelKeys(channelConfig, localParams.fundingKeyPath)
+ val channel = spawnChannel(channelKeys)
+ context.system.scheduler.scheduleOnce(nodeParams.channelConf.channelFundingTimeout, channel, Channel.TickChannelOpenTimeout)(context.dispatcher)
+ log.info(s"accepting a new channel with type=$channelType temporaryChannelId=$temporaryChannelId localParams=$localParams")
+ open match {
+ case Left(open) =>
+ val init = INPUT_INIT_CHANNEL_NON_INITIATOR(
+ temporaryChannelId = open.temporaryChannelId,
+ fundingContribution_opt = None,
+ dualFunded = false,
+ pushAmount_opt = None,
+ requireConfirmedInputs = false,
+ localChannelParams = localParams,
+ proposedCommitParams = nodeParams.channelConf.commitParams(open.fundingSatoshis, channelType, unlimitedMaxHtlcValueInFlight = false),
+ remote = d.peerConnection,
+ remoteInit = d.remoteInit,
+ channelConfig = channelConfig,
+ channelType = channelType)
+ channel ! init
+ channel ! open
+ case Right(open) =>
+ val init = INPUT_INIT_CHANNEL_NON_INITIATOR(
+ temporaryChannelId = open.temporaryChannelId,
+ fundingContribution_opt = addFunding_opt,
+ dualFunded = true,
+ pushAmount_opt = None,
+ requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding,
+ localChannelParams = localParams,
+ proposedCommitParams = nodeParams.channelConf.commitParams(open.fundingAmount + addFunding_opt.map(_.fundingAmount).getOrElse(0 sat), channelType, unlimitedMaxHtlcValueInFlight = false),
+ remote = d.peerConnection,
+ remoteInit = d.remoteInit,
+ channelConfig = channelConfig,
+ channelType = channelType)
+ channel ! init
+ accept.useFeeCredit_opt match {
+ case Some(useFeeCredit) => channel ! open.copy(tlvStream = TlvStream(open.tlvStream.records + ChannelTlv.UseFeeCredit(useFeeCredit)))
+ case None => channel ! open
+ }
}
+ fulfillOnTheFlyFundingHtlcs(accept.preimages)
+ stay() using d.copy(channels = d.channels + (TemporaryChannelId(temporaryChannelId) -> channel))
}
- fulfillOnTheFlyFundingHtlcs(accept.preimages)
- stay() using d.copy(channels = d.channels + (TemporaryChannelId(temporaryChannelId) -> channel))
}
+ case Some(nodeId) =>
+ log.warning("temporary_channel_id={} already used with remoteNodeId={}: aborting remote channel open", temporaryChannelId, nodeId)
+ // Note that we don't bother responding to our peer: they're buggy or malicious anyway.
+ stay()
}
case Event(cmd: ProposeOnTheFlyFunding, d: ConnectedData) if !d.remoteFeatures.hasFeature(Features.OnTheFlyFunding) =>
@@ -561,6 +580,9 @@ class Peer(val nodeParams: NodeParams,
val lastRemoteFeatures = LastRemoteFeatures(d.remoteFeatures, d.remoteFeaturesWritten)
// We only reconnect if our peer is not a mobile wallet, and we now have a channel with them.
val autoReconnect = d.channels.nonEmpty && !d.remoteFeatures.hasFeature(Features.WakeUpNotificationClient)
+ // We stop tracking temporary_channel_ids on disconnection: channels that don't have a final channel_id yet
+ // will be aborted, and channels that do have one will use it from now on.
+ d.channels.collect { case (k: TemporaryChannelId, _) => k }.foreach(channelId => nodeParams.removeChannelId(channelId.id))
goto(DISCONNECTED) using DisconnectedData(d.channels.collect { case (k: FinalChannelId, v) => (k, v) }, d.activeChannels, d.peerStorage, Some(lastRemoteFeatures), autoReconnect)
}
@@ -570,6 +592,7 @@ class Peer(val nodeParams: NodeParams,
val channelIds = d.channels.filter(_._2 == actor).keys
log.info(s"channel closed: channelId=${channelIds.mkString("/")}")
val channels1 = d.channels -- channelIds
+ channelIds.foreach(channelId => nodeParams.removeChannelId(channelId.id))
if (channels1.isEmpty) {
log.info("that was the last open channel, closing the connection")
context.system.eventStream.publish(LastChannelClosed(self, remoteNodeId))
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala
index 85f87af..b615a0e 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala
@@ -63,6 +63,7 @@ class Switchboard(nodeParams: NodeParams, peerFactory: Switchboard.PeerFactory)
}
nodeParams.db.channels.removeChannel(c.channelId, closingData_opt)
})
+ channels.foreach(c => nodeParams.addChannelIdIfAbsent(c.channelId, c.remoteNodeId))
val peersWithChannels = channels.groupBy(_.remoteNodeId)
val peersWithOnTheFlyFunding = nodeParams.db.liquidity.listPendingOnTheFlyFunding()
peersWithChannels.foreach { case (remoteNodeId, states) => createOrGetPeer(remoteNodeId, offlineChannels = states.toSet, peersWithOnTheFlyFunding.getOrElse(remoteNodeId, Map.empty)) }
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala
index 571e2b8..6cf70ca 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala
@@ -17,6 +17,7 @@
package fr.acinq.eclair
import akka.actor.ActorRef
+import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Satoshi, SatoshiLong}
import fr.acinq.eclair.blockchain.fee._
import fr.acinq.eclair.channel._
@@ -161,6 +162,7 @@ object TestConstants {
balanceThresholds = Nil,
minTimeBetweenUpdates = 0 hours,
),
+ channelIds = collection.concurrent.TrieMap.empty[ByteVector32, PublicKey],
onChainFeeConf = OnChainFeeConf(
feeTargets = FeeTargets(funding = ConfirmationPriority.Medium, closing = ConfirmationPriority.Medium),
maxClosingFeerate = FeeratePerKw(15_000 sat),
@@ -383,6 +385,7 @@ object TestConstants {
balanceThresholds = Nil,
minTimeBetweenUpdates = 0 hour,
),
+ channelIds = collection.concurrent.TrieMap.empty[ByteVector32, PublicKey],
onChainFeeConf = OnChainFeeConf(
feeTargets = FeeTargets(funding = ConfirmationPriority.Medium, closing = ConfirmationPriority.Medium),
maxClosingFeerate = FeeratePerKw(15_000 sat),
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala
index c314d28..b8da2a2 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala
@@ -17,7 +17,7 @@
package fr.acinq.eclair.channel
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
-import fr.acinq.bitcoin.scalacompat.{ByteVector64, DeterministicWallet, OutPoint, Satoshi, SatoshiLong, TxOut}
+import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, DeterministicWallet, OutPoint, Satoshi, SatoshiLong, TxOut}
import fr.acinq.eclair.TestUtils.randomTxId
import fr.acinq.eclair._
import fr.acinq.eclair.blockchain.fee._
@@ -487,7 +487,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
object CommitmentsSpec {
- def makeCommitments(toLocal: MilliSatoshi, toRemote: MilliSatoshi, feeRatePerKw: FeeratePerKw = FeeratePerKw(0 sat), dustLimit: Satoshi = 0 sat, isOpener: Boolean = true, announcement_opt: Option[ChannelAnnouncement] = None): Commitments = {
+ def makeCommitments(toLocal: MilliSatoshi, toRemote: MilliSatoshi, feeRatePerKw: FeeratePerKw = FeeratePerKw(0 sat), dustLimit: Satoshi = 0 sat, isOpener: Boolean = true, announcement_opt: Option[ChannelAnnouncement] = None, channelId: ByteVector32 = randomBytes32()): Commitments = {
val channelReserve = (toLocal + toRemote).truncateToSatoshi * 0.01
val localChannelParams = LocalChannelParams(randomKey().publicKey, DeterministicWallet.KeyPath(Seq(42L)), Some(channelReserve), isOpener, isOpener, None, Features.empty)
val remoteChannelParams = RemoteChannelParams(randomKey().publicKey, Some(channelReserve), randomKey().publicKey, randomKey().publicKey, randomKey().publicKey, randomKey().publicKey, Features.empty, None)
@@ -502,7 +502,7 @@ object CommitmentsSpec {
case None => LocalFundingStatus.SingleFundedUnconfirmedFundingTx(None)
}
Commitments(
- ChannelParams(randomBytes32(), ChannelConfig.standard, ChannelFeatures(), localChannelParams, remoteChannelParams, ChannelFlags(announceChannel = announcement_opt.nonEmpty)),
+ ChannelParams(channelId, ChannelConfig.standard, ChannelFeatures(), localChannelParams, remoteChannelParams, ChannelFlags(announceChannel = announcement_opt.nonEmpty)),
CommitmentChanges(LocalChanges(Nil, Nil, Nil), RemoteChanges(Nil, Nil, Nil), localNextHtlcId = 1, remoteNextHtlcId = 1),
List(Commitment(0, 0, OutPoint(randomTxId(), 0), fundingTxOut.amount, remoteFundingPubKey, localFundingStatus, RemoteFundingStatus.Locked, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat, commitParams, localCommit, commitParams, remoteCommit, None)),
inactive = Nil,
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptDualFundedChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptDualFundedChannelStateSpec.scala
index 3745061..321e3f5 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptDualFundedChannelStateSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptDualFundedChannelStateSpec.scala
@@ -236,6 +236,19 @@ class WaitForAcceptDualFundedChannelStateSpec extends TestKitBaseClass with Fixt
aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected]
}
+ test("recv AcceptDualFundedChannel (channel_id already used)", Tag(ChannelStateTestsTags.DualFunding)) { f =>
+ import f._
+ val accept = bob2alice.expectMsgType[AcceptDualFundedChannel]
+ val channelId = Helpers.computeChannelId(open.revocationBasepoint, accept.revocationBasepoint)
+ alice.underlyingActor.nodeParams.addChannelIdIfAbsent(channelId, alice.underlyingActor.remoteNodeId)
+ alice ! accept
+ val error = alice2bob.expectMsgType[Error]
+ assert(error == Error(accept.temporaryChannelId, InvalidFundingTx(channelId).getMessage))
+ listener.expectMsgType[ChannelAborted]
+ awaitCond(alice.stateName == CLOSED)
+ aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected]
+ }
+
test("recv Error", Tag(ChannelStateTestsTags.DualFunding)) { f =>
import f._
alice ! Error(ByteVector32.Zeroes, "dual funding not supported")
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala
index eeb3ac8..9d4efe5 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala
@@ -248,6 +248,18 @@ class WaitForOpenDualFundedChannelStateSpec extends TestKitBaseClass with Fixtur
awaitCond(bob.stateName == CLOSED)
}
+ test("recv OpenDualFundedChannel (channel_id already used)", Tag(ChannelStateTestsTags.DualFunding)) { f =>
+ import f._
+ val open = alice2bob.expectMsgType[OpenDualFundedChannel]
+ val channelId = Helpers.computeChannelId(open.revocationBasepoint, bob.underlyingActor.channelKeys.revocationBasePoint)
+ bob.underlyingActor.nodeParams.addChannelIdIfAbsent(channelId, bob.underlyingActor.remoteNodeId)
+ bob ! open
+ val error = bob2alice.expectMsgType[Error]
+ assert(error == Error(open.temporaryChannelId, InvalidFundingTx(channelId).getMessage))
+ bobListener.expectMsgType[ChannelAborted]
+ awaitCond(bob.stateName == CLOSED)
+ }
+
test("recv Error", Tag(ChannelStateTestsTags.DualFunding)) { f =>
import f._
bob ! Error(ByteVector32.Zeroes, "dual funding not supported")
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingCreatedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingCreatedStateSpec.scala
index b551d9c..99e94ab 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingCreatedStateSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingCreatedStateSpec.scala
@@ -26,7 +26,7 @@ import fr.acinq.eclair.channel.fsm.Channel
import fr.acinq.eclair.channel.publish.TxPublisher
import fr.acinq.eclair.channel.states.ChannelStateTestsBase
import fr.acinq.eclair.wire.protocol._
-import fr.acinq.eclair.{TestConstants, TestKitBaseClass, ToMilliSatoshiConversion}
+import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, TestConstants, TestKitBaseClass, ToMilliSatoshiConversion, toLongId}
import org.scalatest.funsuite.FixtureAnyFunSuiteLike
import org.scalatest.{Outcome, Tag}
@@ -91,6 +91,31 @@ class WaitForFundingCreatedStateSpec extends TestKitBaseClass with FixtureAnyFun
awaitCond(bob.stateName == CLOSED)
}
+ test("recv FundingCreated (channel already exists)") { f =>
+ import f._
+ val fundingCreated = alice2bob.expectMsgType[FundingCreated]
+ // We already have a channel with the same channel_id in our DB.
+ val channelId = toLongId(fundingCreated.fundingTxId, fundingCreated.fundingOutputIndex)
+ val commitments = CommitmentsSpec.makeCommitments(100_000_000 msat, 0 msat, channelId = channelId)
+ bob.underlyingActor.nodeParams.db.channels.addOrUpdateChannel(DATA_WAIT_FOR_FUNDING_CONFIRMED(commitments, BlockHeight(0), None, Left(fundingCreated)))
+ alice2bob.forward(bob, fundingCreated)
+ val error = bob2alice.expectMsgType[Error]
+ assert(error == Error(channelId, InvalidFundingTx(channelId).getMessage))
+ awaitCond(bob.stateName == CLOSED)
+ }
+
+ test("recv FundingCreated (channel_id already used)") { f =>
+ import f._
+ val fundingCreated = alice2bob.expectMsgType[FundingCreated]
+ // We already have a channel with the same channel_id in our channels map.
+ val channelId = toLongId(fundingCreated.fundingTxId, fundingCreated.fundingOutputIndex)
+ bob.underlyingActor.nodeParams.addChannelIdIfAbsent(channelId, bob.underlyingActor.remoteNodeId)
+ alice2bob.forward(bob, fundingCreated)
+ val error = bob2alice.expectMsgType[Error]
+ assert(error == Error(channelId, InvalidFundingTx(channelId).getMessage))
+ awaitCond(bob.stateName == CLOSED)
+ }
+
test("recv Error") { f =>
import f._
bob ! Error(ByteVector32.Zeroes, "oops")
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingInternalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingInternalStateSpec.scala
index 5c1cb3f..46c04a6 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingInternalStateSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingInternalStateSpec.scala
@@ -18,15 +18,18 @@ package fr.acinq.eclair.channel.states.b
import akka.actor.Status
import akka.testkit.{TestFSMRef, TestProbe}
-import fr.acinq.bitcoin.scalacompat.ByteVector32
+import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint, SatoshiLong, Script, Transaction, TxIn, TxOut}
+import fr.acinq.eclair.TestUtils.randomTxId
import fr.acinq.eclair.blockchain.BlockingOnChainWallet
+import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse
import fr.acinq.eclair.channel._
import fr.acinq.eclair.channel.fsm.Channel
import fr.acinq.eclair.channel.fsm.Channel.TickChannelOpenTimeout
import fr.acinq.eclair.channel.states.ChannelStateTestsBase
import fr.acinq.eclair.io.Peer.OpenChannelResponse
+import fr.acinq.eclair.transactions.Scripts
import fr.acinq.eclair.wire.protocol._
-import fr.acinq.eclair.{TestConstants, TestKitBaseClass}
+import fr.acinq.eclair.{TestConstants, TestKitBaseClass, randomKey, toLongId}
import org.scalatest.Outcome
import org.scalatest.funsuite.FixtureAnyFunSuiteLike
@@ -59,6 +62,24 @@ class WaitForFundingInternalStateSpec extends TestKitBaseClass with FixtureAnyFu
}
}
+ test("recv funding tx response (channel_id already used)") { f =>
+ import f._
+ val localFundingPubKey = alice.underlyingActor.channelKeys.fundingKey(fundingTxIndex = 0).publicKey
+ val remoteFundingPubKey = alice.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_INTERNAL].remoteFundingPubKey
+ val fundingTx = Transaction(
+ version = 2,
+ txIn = Seq(TxIn(OutPoint(randomTxId(), 3), Nil, 0)),
+ txOut = Seq(TxOut(100_000 sat, Script.pay2wsh(Scripts.multiSig2of2(localFundingPubKey, remoteFundingPubKey)))),
+ lockTime = 0
+ )
+ val channelId = toLongId(fundingTx.txid, 0)
+ alice.underlyingActor.nodeParams.addChannelIdIfAbsent(channelId, alice.underlyingActor.remoteNodeId)
+ alice ! MakeFundingTxResponse(fundingTx, 0, 150 sat)
+ listener.expectMsgType[ChannelAborted]
+ awaitCond(alice.stateName == CLOSED)
+ aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected]
+ }
+
test("recv Status.Failure (wallet error)") { f =>
import f._
alice ! Status.Failure(new RuntimeException("insufficient funds"))
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala
index 8f31571..95ccca4 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala
@@ -31,7 +31,7 @@ import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsT
import fr.acinq.eclair.crypto.NonceGenerator
import fr.acinq.eclair.io.Peer.OpenChannelResponse
import fr.acinq.eclair.wire.protocol.{AcceptChannel, Error, FundingCreated, FundingSigned, OpenChannel}
-import fr.acinq.eclair.{TestConstants, TestKitBaseClass, randomBytes32, randomKey}
+import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey}
import org.scalatest.funsuite.FixtureAnyFunSuiteLike
import org.scalatest.{Outcome, Tag}
@@ -154,6 +154,20 @@ class WaitForFundingSignedStateSpec extends TestKitBaseClass with FixtureAnyFunS
listener.expectMsgType[ChannelAborted]
}
+ test("recv FundingSigned (channel already exists)") { f =>
+ import f._
+ val fundingSigned = bob2alice.expectMsgType[FundingSigned]
+ // We already have a channel with the same channel_id in our DB.
+ val commitments = CommitmentsSpec.makeCommitments(100_000_000 msat, 0 msat, channelId = fundingSigned.channelId)
+ alice.underlyingActor.nodeParams.db.channels.addOrUpdateChannel(DATA_WAIT_FOR_FUNDING_CONFIRMED(commitments, BlockHeight(0), None, Right(fundingSigned)))
+ bob2alice.forward(alice, fundingSigned)
+ awaitCond(alice.stateName == CLOSED)
+ val error = alice2bob.expectMsgType[Error]
+ assert(error == Error(fundingSigned.channelId, InvalidFundingTx(fundingSigned.channelId).getMessage))
+ aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected]
+ listener.expectMsgType[ChannelAborted]
+ }
+
test("recv CMD_CLOSE") { f =>
import f._
val sender = TestProbe()
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/ChannelsDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/ChannelsDbSpec.scala
index 86a328d..44de10f 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/db/ChannelsDbSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/ChannelsDbSpec.scala
@@ -68,10 +68,14 @@ class ChannelsDbSpec extends AnyFunSuite {
val cltvExpiry2 = CltvExpiry(656)
assert(db.listLocalChannels().isEmpty)
+ assert(db.addChannel(channel1).isEmpty)
db.addOrUpdateChannel(channel1)
- db.addOrUpdateChannel(channel1)
+ assert(db.addChannel(channel1).nonEmpty)
+ assert(db.addChannel(channel1.modify(_.aliases.localAlias).setTo(Alias(randomLong()))).nonEmpty)
assert(db.listLocalChannels() == List(channel1))
db.addOrUpdateChannel(channel2a)
+ assert(db.addChannel(channel2a).nonEmpty)
+ assert(db.addChannel(channel2b).nonEmpty)
assert(db.listLocalChannels() == List(channel1, channel2a))
assert(db.getChannel(channel1.channelId).contains(channel1))
assert(db.getChannel(channel2a.channelId).contains(channel2a))
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala
index ac7e9ed..df388f4 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala
@@ -442,10 +442,25 @@ class PeerSpec extends FixtureSpec {
channel.expectMsg(open)
// open_channel messages with the same temporary channel id should simply be ignored
- peerConnection.send(peer, open.copy(fundingSatoshis = 100000 sat, fundingPubkey = randomKey().publicKey))
+ val open1 = open.copy(fundingSatoshis = 100000 sat, fundingPubkey = randomKey().publicKey)
+ peerConnection.send(peer, open1)
channel.expectNoMessage(100 millis)
peerConnection.expectNoMessage(100 millis)
assert(peer.stateData.channels.size == 1)
+
+ // when the channel transitions to a final channel id, we still disallow reusing the temporary channel id
+ channel.send(peer, ChannelIdAssigned(channel.ref, remoteNodeId, open.temporaryChannelId, randomBytes32()))
+ peerConnection.expectMsgType[PeerConnection.DoSync]
+ val open2 = open.copy(fundingSatoshis = 150000 sat, fundingPubkey = randomKey().publicKey)
+ peerConnection.send(peer, open2)
+ channel.expectNoMessage(100 millis)
+ peerConnection.expectNoMessage(100 millis)
+
+ // if the channel is aborted, the temporary channel id can be reused in another attempt
+ peerConnection.send(peer, ChannelTerminated(channel.ref))
+ peerConnection.send(peer, open2)
+ assert(channel.expectMsgType[INPUT_INIT_CHANNEL_NON_INITIATOR].temporaryChannelId == open.temporaryChannelId)
+ channel.expectMsg(open2)
}
test("don't spawn a channel that reuses an existing channel id") { f =>
@@ -465,6 +480,27 @@ class PeerSpec extends FixtureSpec {
assert(peer.stateData.channels.size == 1)
}
+ test("remove temporary_channel_id from the global map on disconnection") { f =>
+ import f._
+
+ val probe = TestProbe()
+ connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal.withChannelKeys(nodeParams)))
+ channel.expectMsgType[INPUT_RESTORED]
+ channel.expectMsgType[INPUT_RECONNECTED]
+
+ // peer receives an open_channel and spawns a non-initiator channel actor
+ val open = createOpenChannelMessage(ChannelTypes.AnchorOutputsZeroFeeHtlcTx())
+ peerConnection.send(peer, open)
+ assert(channel.expectMsgType[INPUT_INIT_CHANNEL_NON_INITIATOR].temporaryChannelId == open.temporaryChannelId)
+ channel.expectMsg(open)
+ // the peer disconnects before transitioning to a final channel_id
+ peerConnection.send(peer, ConnectionDown(peerConnection.ref))
+ probe.send(peer, Peer.GetPeerInfo(Some(probe.ref.toTyped)))
+ assert(probe.expectMsgType[Peer.PeerInfo].state == Peer.DISCONNECTED)
+ // the temporary channel id is removed from the global channels map
+ assert(nodeParams.removeChannelId(open.temporaryChannelId).isEmpty)
+ }
+
test("send error when receiving message for unknown channel") { f =>
import f._
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala
index d44a64a..f906376 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/SwitchboardSpec.scala
@@ -27,13 +27,22 @@ class SwitchboardSpec extends TestKitBaseClass with AnyFunSuiteLike {
test("on initialization create peers") {
val nodeParams = Alice.nodeParams
val (probe, peer) = (TestProbe(), TestProbe())
- val remoteNodeId = ChannelCodecsSpec.normal.remoteNodeId
+ val channelData = ChannelCodecsSpec.normal.withChannelKeys(nodeParams)
// If we have a channel with that remote peer, we will automatically reconnect.
-
val switchboard = TestActorRef(new Switchboard(nodeParams, FakePeerFactory(probe, peer)))
- switchboard ! Switchboard.Init(List(ChannelCodecsSpec.normal.withChannelKeys(nodeParams)))
- probe.expectMsg(remoteNodeId)
- peer.expectMsg(Peer.Init(Set(ChannelCodecsSpec.normal.withChannelKeys(nodeParams)), Map.empty))
+ switchboard ! Switchboard.Init(List(channelData))
+ probe.expectMsg(channelData.remoteNodeId)
+ peer.expectMsg(Peer.Init(Set(channelData), Map.empty))
+ // The channel has been added to the shared channels map.
+ assert(nodeParams.addChannelIdIfAbsent(channelData.channelId, randomKey().publicKey).contains(channelData.remoteNodeId))
+ // We can add and remove channels from the shared channels map after initializing peers.
+ val otherChannel = randomBytes32()
+ val otherNodeId = randomKey().publicKey
+ assert(nodeParams.addChannelIdIfAbsent(otherChannel, otherNodeId).isEmpty)
+ assert(nodeParams.removeChannelId(randomBytes32()).isEmpty)
+ assert(nodeParams.removeChannelId(otherChannel).contains(otherNodeId))
+ assert(nodeParams.removeChannelId(otherChannel).isEmpty)
+ assert(nodeParams.addChannelIdIfAbsent(otherChannel, channelData.remoteNodeId).isEmpty)
}
test("on initialization create peers with pending on-the-fly funding proposals") {
Why this scored 60/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.